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                                                let include_summary = !streamed_reasoning_summaries
1198                                                    .contains(&block_index);
1199                                                for delta in output_item_stream_deltas(
1200                                                    &item,
1201                                                    block_index,
1202                                                    include_summary,
1203                                                ) {
1204                                                    emitted_output = true;
1205                                                    yield Ok(delta);
1206                                                }
1207                                                if let Some(item) = output_item_to_input_item(item) {
1208                                                    response_items.push(item);
1209                                                }
1210                                            }
1211                                            "response.completed"
1212                                            | "response.incomplete"
1213                                            | "response.done" => {
1214                                                let response_status = event
1215                                                    .response
1216                                                    .as_ref()
1217                                                    .and_then(|response| response.status);
1218                                                let incomplete_reason = event
1219                                                    .response
1220                                                    .as_ref()
1221                                                    .and_then(|response| {
1222                                                        response.incomplete_details.as_ref()
1223                                                    })
1224                                                    .and_then(|details| details.reason.clone());
1225                                                if let Some(resp) = event.response {
1226                                                    if let Some(u) = resp.usage {
1227                                                        usage = Some(usage_from_api_usage(&u));
1228                                                    }
1229                                                    if let Some(id) = resp.id {
1230                                                        response_id = Some(id);
1231                                                    }
1232                                                }
1233                                                let final_status = match event.r#type.as_str() {
1234                                                    "response.incomplete" => {
1235                                                        Some(ApiStatus::Incomplete)
1236                                                    }
1237                                                    "response.done" => response_status
1238                                                        .or(Some(ApiStatus::Completed)),
1239                                                    _ => Some(ApiStatus::Completed),
1240                                                };
1241                                                let stop_reason = stop_reason_from_stream_state(
1242                                                    &tool_calls,
1243                                                    final_status,
1244                                                    refused,
1245                                                    incomplete_reason.as_deref(),
1246                                                );
1247                                                if stop_reason == Some(StopReason::ToolUse) {
1248                                                    for delta in
1249                                                        emit_accumulated_tool_calls(&tool_calls)
1250                                                    {
1251                                                        yield Ok(delta);
1252                                                    }
1253                                                }
1254                                                if let Some(u) = usage.take() {
1255                                                    yield Ok(StreamDelta::Usage(u));
1256                                                }
1257                                                websocket_session.last_request = Some(api_request.clone());
1258                                                websocket_session.last_response_id = response_id;
1259                                                websocket_session.last_response_items = response_items;
1260                                                websocket_session.prewarmed = false;
1261                                                // Clean completion: the turn is no
1262                                                // longer in flight, so the next turn
1263                                                // may reuse this connection/baseline.
1264                                                websocket_session.in_flight = false;
1265                                                yield Ok(StreamDelta::Done {
1266                                                    stop_reason,
1267                                                });
1268                                                return;
1269                                            }
1270                                            "response.failed" => {
1271                                                websocket_session.last_request = None;
1272                                                websocket_session.last_response_id = None;
1273                                                websocket_session.last_response_items.clear();
1274                                                websocket_session.prewarmed = false;
1275                                                let message = event
1276                                                    .response
1277                                                    .and_then(|resp| resp.error)
1278                                                    .and_then(|error| error.message)
1279                                                    .unwrap_or_else(|| {
1280                                                        "Codex response failed".to_string()
1281                                                    });
1282                                                yield Ok(StreamDelta::Error {
1283                                                    message,
1284                                                    kind: StreamErrorKind::ServerError,
1285                                                });
1286                                                return;
1287                                            }
1288                                            _ => {}
1289                                    }
1290                                }
1291                                WebSocketMessage::Binary(bytes) => {
1292                                    let text = match String::from_utf8(bytes.to_vec()) {
1293                                        Ok(text) => text,
1294                                        Err(error) => {
1295                                            reset_websocket_connection(&mut websocket_session);
1296                                            yield Ok(StreamDelta::Error {
1297                                                message: format!(
1298                                                    "invalid OpenAI Codex websocket UTF-8: {error}"
1299                                                ),
1300                                                kind: StreamErrorKind::ServerError,
1301                                            });
1302                                            return;
1303                                        }
1304                                    };
1305                                        if let Some((status, message)) =
1306                                            parse_wrapped_websocket_error_event(&text)
1307                                        {
1308                                            let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1309                                                StreamErrorKind::RateLimited
1310                                            } else if status.is_server_error() {
1311                                                StreamErrorKind::ServerError
1312                                            } else {
1313                                                StreamErrorKind::InvalidRequest
1314                                            };
1315                                            if emitted_output {
1316                                                reset_websocket_connection(&mut websocket_session);
1317                                                yield Ok(StreamDelta::Error {
1318                                                    message,
1319                                                    kind,
1320                                                });
1321                                                return;
1322                                            }
1323                                            if status == StatusCode::UNAUTHORIZED
1324                                                || status == StatusCode::UPGRADE_REQUIRED
1325                                                || status.is_client_error()
1326                                            {
1327                                                websocket_session.websocket_disabled = true;
1328                                            }
1329                                            reset_websocket_connection(&mut websocket_session);
1330                                            continue 'websocket_attempts;
1331                                        }
1332
1333                                        let event = match decode_stream_event(&text) {
1334                                            Ok(event) => event,
1335                                            Err(error) => {
1336                                                reset_websocket_connection(
1337                                                    &mut websocket_session,
1338                                                );
1339                                                yield Ok(StreamDelta::Error {
1340                                                    message: error.to_string(),
1341                                                    kind: StreamErrorKind::ServerError,
1342                                                });
1343                                                return;
1344                                            }
1345                                        };
1346                                            match event.r#type.as_str() {
1347                                                "response.output_text.delta" => {
1348                                                    if let Some(delta) = event.delta {
1349                                                        emitted_output = true;
1350                                                        yield Ok(StreamDelta::TextDelta {
1351                                                            delta,
1352                                                            block_index: output_block_index(event.output_index),
1353                                                        });
1354                                                    }
1355                                                }
1356                                                "response.refusal.delta" => {
1357                                                    refused = true;
1358                                                    if let Some(delta) = event.delta {
1359                                                        emitted_output = true;
1360                                                        yield Ok(StreamDelta::TextDelta {
1361                                                            delta,
1362                                                            block_index: output_block_index(
1363                                                                event.output_index,
1364                                                            ),
1365                                                        });
1366                                                    }
1367                                                }
1368                                                "response.reasoning_summary_text.delta" => {
1369                                                    if let Some(delta) = event.delta {
1370                                                        let output_index =
1371                                                            event.output_index.unwrap_or(0);
1372                                                        streamed_reasoning_summaries
1373                                                            .insert(output_index);
1374                                                        emitted_output = true;
1375                                                        yield Ok(StreamDelta::ThinkingDelta {
1376                                                            delta,
1377                                                            block_index:
1378                                                                reasoning_summary_block_index(Some(
1379                                                                    output_index,
1380                                                                )),
1381                                                        });
1382                                                    }
1383                                                }
1384                                                "response.function_call_arguments.delta" => {
1385                                                    let block_index = event
1386                                                        .output_index
1387                                                        .map(|index| index.saturating_mul(2));
1388                                                    if let (Some(call_id), Some(delta)) =
1389                                                        (event.call_id, event.delta)
1390                                                    {
1391                                                        emitted_output = true;
1392                                                        let order = tool_calls.len();
1393                                                        let acc = tool_calls
1394                                                            .entry(call_id.clone())
1395                                                            .or_insert_with(|| ToolCallAccumulator {
1396                                                                id: call_id,
1397                                                                name: event.name.unwrap_or_default(),
1398                                                                arguments: String::new(),
1399                                                                order,
1400                                                                block_index,
1401                                                            });
1402                                                        acc.arguments.push_str(&delta);
1403                                                    }
1404                                                }
1405                                                "response.output_item.done" => {
1406                                                    let item =
1407                                                        match decode_output_item(event.item) {
1408                                                            Ok(item) => item,
1409                                                            Err(error) => {
1410                                                                reset_websocket_connection(
1411                                                                    &mut websocket_session,
1412                                                                );
1413                                                                yield Ok(StreamDelta::Error {
1414                                                                    message: error.to_string(),
1415                                                                    kind: StreamErrorKind::ServerError,
1416                                                                });
1417                                                                return;
1418                                                            }
1419                                                        };
1420                                                    let block_index =
1421                                                        event.output_index.unwrap_or(0);
1422                                                    let include_summary =
1423                                                        !streamed_reasoning_summaries
1424                                                            .contains(&block_index);
1425                                                    for delta in output_item_stream_deltas(
1426                                                        &item,
1427                                                        block_index,
1428                                                        include_summary,
1429                                                    ) {
1430                                                        emitted_output = true;
1431                                                        yield Ok(delta);
1432                                                    }
1433                                                    if let Some(item) =
1434                                                        output_item_to_input_item(item)
1435                                                    {
1436                                                        response_items.push(item);
1437                                                    }
1438                                                }
1439                                                "response.completed"
1440                                                | "response.incomplete"
1441                                                | "response.done" => {
1442                                                    let response_status = event
1443                                                        .response
1444                                                        .as_ref()
1445                                                        .and_then(|response| response.status);
1446                                                    let incomplete_reason = event
1447                                                        .response
1448                                                        .as_ref()
1449                                                        .and_then(|response| {
1450                                                            response.incomplete_details.as_ref()
1451                                                        })
1452                                                        .and_then(|details| details.reason.clone());
1453                                                    if let Some(resp) = event.response {
1454                                                        if let Some(u) = resp.usage {
1455                                                            usage = Some(usage_from_api_usage(&u));
1456                                                        }
1457                                                        if let Some(id) = resp.id {
1458                                                            response_id = Some(id);
1459                                                        }
1460                                                    }
1461                                                    let final_status =
1462                                                        match event.r#type.as_str() {
1463                                                            "response.incomplete" => {
1464                                                                Some(ApiStatus::Incomplete)
1465                                                            }
1466                                                            "response.done" => response_status
1467                                                                .or(Some(ApiStatus::Completed)),
1468                                                            _ => Some(ApiStatus::Completed),
1469                                                        };
1470                                                    let stop_reason =
1471                                                        stop_reason_from_stream_state(
1472                                                            &tool_calls,
1473                                                            final_status,
1474                                                            refused,
1475                                                            incomplete_reason.as_deref(),
1476                                                        );
1477                                                    if stop_reason
1478                                                        == Some(StopReason::ToolUse)
1479                                                    {
1480                                                        for delta in
1481                                                            emit_accumulated_tool_calls(&tool_calls)
1482                                                        {
1483                                                            yield Ok(delta);
1484                                                        }
1485                                                    }
1486                                                    if let Some(u) = usage.take() {
1487                                                        yield Ok(StreamDelta::Usage(u));
1488                                                    }
1489                                                    websocket_session.last_request =
1490                                                        Some(api_request.clone());
1491                                                    websocket_session.last_response_id = response_id;
1492                                                    websocket_session.last_response_items =
1493                                                        response_items;
1494                                                    websocket_session.prewarmed = false;
1495                                                    // Clean completion: the turn is no
1496                                                    // longer in flight, so the next
1497                                                    // turn may reuse the connection.
1498                                                    websocket_session.in_flight = false;
1499                                                    yield Ok(StreamDelta::Done {
1500                                                        stop_reason,
1501                                                    });
1502                                                    return;
1503                                                }
1504                                                "response.failed" => {
1505                                                    websocket_session.last_request = None;
1506                                                    websocket_session.last_response_id = None;
1507                                                    websocket_session.last_response_items.clear();
1508                                                    websocket_session.prewarmed = false;
1509                                                    let message = event
1510                                                        .response
1511                                                        .and_then(|resp| resp.error)
1512                                                        .and_then(|error| error.message)
1513                                                        .unwrap_or_else(|| {
1514                                                            "Codex response failed".to_string()
1515                                                        });
1516                                                    yield Ok(StreamDelta::Error {
1517                                                        message,
1518                                                        kind: StreamErrorKind::ServerError,
1519                                                    });
1520                                                    return;
1521                                                }
1522                                                _ => {}
1523                                    }
1524                                }
1525                                WebSocketMessage::Ping(payload) => {
1526                                    if let Some(connection) = websocket_session.connection.as_mut()
1527                                        && let Err(error) = connection
1528                                            .send(WebSocketMessage::Pong(payload))
1529                                            .await
1530                                    {
1531                                        if emitted_output {
1532                                            reset_websocket_connection(&mut websocket_session);
1533                                            yield Ok(StreamDelta::Error {
1534                                                message: format!("websocket pong failed: {error}"),
1535                                                kind: StreamErrorKind::ServerError,
1536                                            });
1537                                            return;
1538                                        }
1539                                        reset_websocket_connection(&mut websocket_session);
1540                                        if attempt == 1 {
1541                                            websocket_session.websocket_disabled = true;
1542                                            mark_websocket_transport_unhealthy();
1543                                        }
1544                                        continue 'websocket_attempts;
1545                                    }
1546                                }
1547                                WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {}
1548                                WebSocketMessage::Close(_) => {
1549                                    if emitted_output {
1550                                        reset_websocket_connection(&mut websocket_session);
1551                                        yield Ok(StreamDelta::Error {
1552                                            message: "websocket closed before response.completed"
1553                                                .to_string(),
1554                                            kind: StreamErrorKind::ServerError,
1555                                        });
1556                                        return;
1557                                    }
1558                                    reset_websocket_connection(&mut websocket_session);
1559                                    if attempt == 1 {
1560                                        websocket_session.websocket_disabled = true;
1561                                        mark_websocket_transport_unhealthy();
1562                                    }
1563                                    continue 'websocket_attempts;
1564                                }
1565                            }
1566                        }
1567                    }
1568                }
1569                // The websocket turn did not complete (disabled, or attempts
1570                // exhausted); the turn now falls through to the HTTP SSE path, so
1571                // no websocket turn is in flight.
1572                websocket_session.in_flight = false;
1573                sse_turn_state = websocket_session.turn_state.clone();
1574                drop(websocket_session);
1575            }
1576
1577            let headers = match self.build_headers(
1578                true,
1579                request.session_id.as_deref(),
1580                sse_turn_state.as_deref(),
1581            ) {
1582                Ok(headers) => headers,
1583                Err(error) => {
1584                    yield Ok(StreamDelta::Error {
1585                        message: error.to_string(),
1586                        kind: StreamErrorKind::InvalidRequest,
1587                    });
1588                    return;
1589                }
1590            };
1591
1592            let Ok(response) = self.client
1593                .post(codex_url(&self.base_url))
1594                .headers(headers)
1595                .json(&api_request)
1596                .send()
1597                .await
1598            else {
1599                yield Err(anyhow::anyhow!("request failed"));
1600                return;
1601            };
1602
1603            let status = response.status();
1604            if !status.is_success() {
1605                let body = response.text().await.unwrap_or_default();
1606                let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1607                    StreamErrorKind::RateLimited
1608                } else if status.is_server_error() {
1609                    StreamErrorKind::ServerError
1610                } else {
1611                    StreamErrorKind::InvalidRequest
1612                };
1613                log::warn!("OpenAI Codex error status={status} body={body}");
1614                yield Ok(StreamDelta::Error { message: body, kind });
1615                return;
1616            }
1617
1618            if let Some(session_id) = request.session_id.as_deref() {
1619                let turn_state = response
1620                    .headers()
1621                    .get(OPENAI_CODEX_TURN_STATE_HEADER)
1622                    .and_then(|value| value.to_str().ok())
1623                    .map(ToOwned::to_owned);
1624                if let Some(turn_state) = turn_state {
1625                    let session = self.websocket_session(session_id).await;
1626                    let mut websocket_session = session.lock().await;
1627                    websocket_session.turn_state = Some(turn_state);
1628                }
1629            }
1630
1631            let mut sse = SseLineBuffer::new();
1632            let mut stream = response.bytes_stream();
1633            let mut usage: Option<Usage> = None;
1634            let mut tool_calls: HashMap<String, ToolCallAccumulator> = HashMap::new();
1635            let mut final_status: Option<ApiStatus> = None;
1636            let mut streamed_reasoning_summaries = HashSet::new();
1637            let mut refused = false;
1638            let mut incomplete_reason: Option<String> = None;
1639
1640            while let Some(chunk_result) = stream.next().await {
1641                let Ok(chunk) = chunk_result else {
1642                    yield Err(anyhow::anyhow!("stream error"));
1643                    return;
1644                };
1645                sse.extend(&chunk);
1646
1647                while let Some(line) = sse.next_line() {
1648                    let line = line.trim();
1649                    if line.is_empty() {
1650                        continue;
1651                    }
1652
1653                    let Some(data) = line.strip_prefix("data: ") else {
1654                        continue;
1655                    };
1656
1657                    if data == "[DONE]" {
1658                        let Some(stop_reason) =
1659                            stop_reason_from_stream_state(
1660                                &tool_calls,
1661                                final_status,
1662                                refused,
1663                                incomplete_reason.as_deref(),
1664                            )
1665                        else {
1666                            yield Ok(StreamDelta::Error {
1667                                message: "OpenAI Codex stream sent [DONE] before a terminal response event"
1668                                    .to_owned(),
1669                                kind: StreamErrorKind::ServerError,
1670                            });
1671                            return;
1672                        };
1673                        if stop_reason == StopReason::ToolUse {
1674                            for delta in emit_accumulated_tool_calls(&tool_calls) {
1675                                yield Ok(delta);
1676                            }
1677                        }
1678                        if let Some(u) = usage.take() {
1679                            yield Ok(StreamDelta::Usage(u));
1680                        }
1681                        yield Ok(StreamDelta::Done {
1682                            stop_reason: Some(stop_reason),
1683                        });
1684                        return;
1685                    }
1686
1687                    let event = match decode_stream_event(data) {
1688                        Ok(event) => event,
1689                        Err(error) => {
1690                            yield Ok(StreamDelta::Error {
1691                                message: format!(
1692                                    "invalid OpenAI Codex Responses stream event: {error}"
1693                                ),
1694                                kind: StreamErrorKind::ServerError,
1695                            });
1696                            return;
1697                        }
1698                    };
1699                    match event.r#type.as_str() {
1700                            "response.output_text.delta" => {
1701                                if let Some(delta) = event.delta {
1702                                    yield Ok(StreamDelta::TextDelta {
1703                                        delta,
1704                                        block_index: output_block_index(event.output_index),
1705                                    });
1706                                }
1707                            }
1708                            "response.refusal.delta" => {
1709                                refused = true;
1710                                if let Some(delta) = event.delta {
1711                                    yield Ok(StreamDelta::TextDelta {
1712                                        delta,
1713                                        block_index: output_block_index(event.output_index),
1714                                    });
1715                                }
1716                            }
1717                            "response.reasoning_summary_text.delta" => {
1718                                if let Some(delta) = event.delta {
1719                                    let output_index = event.output_index.unwrap_or(0);
1720                                    streamed_reasoning_summaries.insert(output_index);
1721                                    yield Ok(StreamDelta::ThinkingDelta {
1722                                        delta,
1723                                        block_index: reasoning_summary_block_index(Some(
1724                                            output_index,
1725                                        )),
1726                                    });
1727                                }
1728                            }
1729                            "response.function_call_arguments.delta" => {
1730                                let block_index = event
1731                                    .output_index
1732                                    .map(|index| index.saturating_mul(2));
1733                                if let (Some(call_id), Some(delta)) = (event.call_id, event.delta) {
1734                                    let order = tool_calls.len();
1735                                    let acc = tool_calls.entry(call_id.clone()).or_insert_with(|| {
1736                                        ToolCallAccumulator {
1737                                            id: call_id,
1738                                            name: event.name.unwrap_or_default(),
1739                                            arguments: String::new(),
1740                                            order,
1741                                            block_index,
1742                                        }
1743                                    });
1744                                    acc.arguments.push_str(&delta);
1745                                }
1746                            }
1747                            "response.output_item.done" => {
1748                                let item = match decode_output_item(event.item) {
1749                                    Ok(item) => item,
1750                                    Err(error) => {
1751                                        yield Ok(StreamDelta::Error {
1752                                            message: error.to_string(),
1753                                            kind: StreamErrorKind::ServerError,
1754                                        });
1755                                        return;
1756                                    }
1757                                };
1758                                let block_index = event.output_index.unwrap_or(0);
1759                                let include_summary =
1760                                    !streamed_reasoning_summaries.contains(&block_index);
1761                                for delta in output_item_stream_deltas(
1762                                    &item,
1763                                    block_index,
1764                                    include_summary,
1765                                ) {
1766                                    yield Ok(delta);
1767                                }
1768                            }
1769                            "response.completed" | "response.incomplete" | "response.done" => {
1770                                let response_status = event
1771                                    .response
1772                                    .as_ref()
1773                                    .and_then(|response| response.status);
1774                                incomplete_reason = event
1775                                    .response
1776                                    .as_ref()
1777                                    .and_then(|response| response.incomplete_details.as_ref())
1778                                    .and_then(|details| details.reason.clone());
1779                                if let Some(resp) = event.response
1780                                    && let Some(u) = resp.usage
1781                                {
1782                                    usage = Some(usage_from_api_usage(&u));
1783                                }
1784                                final_status = match event.r#type.as_str() {
1785                                    "response.incomplete" => Some(ApiStatus::Incomplete),
1786                                    "response.done" => {
1787                                        response_status.or(Some(ApiStatus::Completed))
1788                                    }
1789                                    _ => Some(ApiStatus::Completed),
1790                                };
1791                            }
1792                            "response.failed" => {
1793                                let message = event
1794                                    .response
1795                                    .and_then(|resp| resp.error)
1796                                    .and_then(|error| error.message)
1797                                    .unwrap_or_else(|| "Codex response failed".to_string());
1798                                yield Ok(StreamDelta::Error {
1799                                    message,
1800                                    kind: StreamErrorKind::ServerError,
1801                                });
1802                                return;
1803                            }
1804                            _ => {}
1805                    }
1806                }
1807            }
1808
1809            let Some(stop_reason) = stop_reason_from_stream_state(
1810                &tool_calls,
1811                final_status,
1812                refused,
1813                incomplete_reason.as_deref(),
1814            ) else {
1815                yield Ok(StreamDelta::Error {
1816                    message: "OpenAI Codex stream ended before a terminal response event".to_owned(),
1817                    kind: StreamErrorKind::ServerError,
1818                });
1819                return;
1820            };
1821            if stop_reason == StopReason::ToolUse {
1822                for delta in emit_accumulated_tool_calls(&tool_calls) {
1823                    yield Ok(delta);
1824                }
1825            }
1826            if let Some(u) = usage {
1827                yield Ok(StreamDelta::Usage(u));
1828            }
1829            yield Ok(StreamDelta::Done {
1830                stop_reason: Some(stop_reason),
1831            });
1832        })
1833    }
1834
1835    fn model(&self) -> &str {
1836        &self.model
1837    }
1838
1839    fn provider(&self) -> &'static str {
1840        "openai-codex"
1841    }
1842
1843    fn configured_thinking(&self) -> Option<&ThinkingConfig> {
1844        self.thinking.as_ref()
1845    }
1846}
1847
1848// ============================================================================
1849// Input building
1850// ============================================================================
1851
1852fn build_api_input(request: &ChatRequest) -> Vec<ApiInputItem> {
1853    let mut items = Vec::new();
1854
1855    // Convert user/assistant messages. The system prompt is sent separately as
1856    // `instructions`, matching pi's Codex transport.
1857    for msg in &request.messages {
1858        let role = match msg.role {
1859            agent_sdk_foundation::llm::Role::User => ApiRole::User,
1860            agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
1861        };
1862        match &msg.content {
1863            Content::Text(text) => {
1864                items.push(ApiInputItem::Message(ApiMessage {
1865                    role,
1866                    content: ApiMessageContent::Text(text.clone()),
1867                    phase: api_message_phase(role, false),
1868                }));
1869            }
1870            Content::Blocks(blocks) => append_block_input(&mut items, role, blocks),
1871        }
1872    }
1873
1874    items
1875}
1876
1877fn append_block_input(items: &mut Vec<ApiInputItem>, role: ApiRole, blocks: &[ContentBlock]) {
1878    let mut content_parts = Vec::new();
1879    let mut phase = api_message_phase(
1880        role,
1881        blocks
1882            .iter()
1883            .any(|block| matches!(block, ContentBlock::ToolUse { .. })),
1884    );
1885
1886    for block in blocks {
1887        match block {
1888            ContentBlock::Text { text } => {
1889                let part = if matches!(role, ApiRole::Assistant) {
1890                    ApiInputContent::OutputText { text: text.clone() }
1891                } else {
1892                    ApiInputContent::InputText { text: text.clone() }
1893                };
1894                content_parts.push(part);
1895            }
1896            ContentBlock::OpaqueReasoning { provider, data }
1897                if matches!(role, ApiRole::Assistant)
1898                    && is_message_state_marker(provider, data) =>
1899            {
1900                flush_message_parts(items, role, phase.clone(), &mut content_parts);
1901                phase = data
1902                    .get("phase")
1903                    .and_then(serde_json::Value::as_str)
1904                    .map(ToOwned::to_owned);
1905            }
1906            ContentBlock::OpaqueReasoning { provider, data }
1907                if provider == OPENAI_RESPONSES_REASONING_PROVIDER
1908                    && data.get("type").and_then(serde_json::Value::as_str)
1909                        == Some("reasoning") =>
1910            {
1911                flush_message_parts(items, role, phase.clone(), &mut content_parts);
1912                items.push(ApiInputItem::OpaqueReasoning(data.clone()));
1913            }
1914            ContentBlock::Thinking { .. }
1915            | ContentBlock::RedactedThinking { .. }
1916            | ContentBlock::OpaqueReasoning { .. } => {}
1917            ContentBlock::Image { source } => content_parts.push(ApiInputContent::Image {
1918                image_url: format!("data:{};base64,{}", source.media_type, source.data),
1919            }),
1920            ContentBlock::Document { source } => content_parts.push(ApiInputContent::File {
1921                filename: suggested_filename(&source.media_type),
1922                file_data: format!("data:{};base64,{}", source.media_type, source.data),
1923            }),
1924            ContentBlock::ToolUse {
1925                id, name, input, ..
1926            } => {
1927                flush_message_parts(items, role, phase.clone(), &mut content_parts);
1928                items.push(ApiInputItem::FunctionCall(ApiFunctionCall::new(
1929                    id.clone(),
1930                    name.clone(),
1931                    serde_json::to_string(input).unwrap_or_default(),
1932                )));
1933            }
1934            ContentBlock::ToolResult {
1935                tool_use_id,
1936                content,
1937                ..
1938            } => {
1939                flush_message_parts(items, role, phase.clone(), &mut content_parts);
1940                items.push(ApiInputItem::FunctionCallOutput(
1941                    ApiFunctionCallOutput::new(tool_use_id.clone(), content.clone()),
1942                ));
1943            }
1944            _ => log::warn!("Skipping unrecognized OpenAI Responses content block"),
1945        }
1946    }
1947
1948    flush_message_parts(items, role, phase, &mut content_parts);
1949}
1950
1951fn api_message_phase(role: ApiRole, has_tool_use: bool) -> Option<String> {
1952    match (role, has_tool_use) {
1953        (ApiRole::Assistant, true) => Some("commentary".to_owned()),
1954        (ApiRole::Assistant, false) => Some("final_answer".to_owned()),
1955        (ApiRole::User, _) => None,
1956    }
1957}
1958
1959fn message_state_marker(role: &str, phase: Option<&str>) -> serde_json::Value {
1960    let mut marker = serde_json::Map::new();
1961    marker.insert(
1962        "type".to_owned(),
1963        serde_json::Value::String(OPENAI_MESSAGE_ITEM_TYPE.to_owned()),
1964    );
1965    marker.insert(
1966        "role".to_owned(),
1967        serde_json::Value::String(role.to_owned()),
1968    );
1969    if let Some(phase) = phase {
1970        marker.insert(
1971            "phase".to_owned(),
1972            serde_json::Value::String(phase.to_owned()),
1973        );
1974    }
1975    serde_json::Value::Object(marker)
1976}
1977
1978fn is_message_state_marker(provider: &str, data: &serde_json::Value) -> bool {
1979    provider == OPENAI_RESPONSES_REASONING_PROVIDER
1980        && data.get("type").and_then(serde_json::Value::as_str) == Some(OPENAI_MESSAGE_ITEM_TYPE)
1981        && data.get("content").is_none()
1982}
1983
1984fn flush_message_parts(
1985    items: &mut Vec<ApiInputItem>,
1986    role: ApiRole,
1987    phase: Option<String>,
1988    content_parts: &mut Vec<ApiInputContent>,
1989) {
1990    if content_parts.is_empty() {
1991        return;
1992    }
1993    items.push(ApiInputItem::Message(ApiMessage {
1994        role,
1995        content: ApiMessageContent::Parts(std::mem::take(content_parts)),
1996        phase,
1997    }));
1998}
1999
2000/// Recursively fix a JSON schema for `OpenAI` strict mode.
2001///
2002/// Adds `additionalProperties: false`, marks every property required, and — to
2003/// keep previously-optional properties from being forced to fabricated values —
2004/// wraps optional properties in `anyOf: [..., {"type": "null"}]`. This mirrors
2005/// the sibling `openai_responses` provider so a tool schema behaves identically
2006/// across both providers.
2007fn fix_schema_for_strict_mode(schema: &mut serde_json::Value) {
2008    let Some(obj) = schema.as_object_mut() else {
2009        return;
2010    };
2011
2012    // Check if this is an object type schema
2013    let is_object_type = obj
2014        .get("type")
2015        .is_some_and(|t| t.as_str() == Some("object"));
2016
2017    if is_object_type {
2018        // Add additionalProperties: false
2019        obj.insert(
2020            "additionalProperties".to_owned(),
2021            serde_json::Value::Bool(false),
2022        );
2023
2024        // Ensure properties and required exist (strict mode needs them even if empty)
2025        obj.entry("properties".to_owned())
2026            .or_insert_with(|| serde_json::json!({}));
2027        obj.entry("required".to_owned())
2028            .or_insert_with(|| serde_json::json!([]));
2029
2030        // Collect the set of originally required keys
2031        let originally_required: std::collections::HashSet<String> = obj
2032            .get("required")
2033            .and_then(|v| v.as_array())
2034            .map(|arr| {
2035                arr.iter()
2036                    .filter_map(|v| v.as_str().map(String::from))
2037                    .collect()
2038            })
2039            .unwrap_or_default();
2040
2041        // Wrap previously-optional properties in anyOf with null
2042        if let Some(serde_json::Value::Object(props)) = obj.get_mut("properties") {
2043            for (key, prop_schema) in props.iter_mut() {
2044                if !originally_required.contains(key) {
2045                    make_nullable(prop_schema);
2046                }
2047            }
2048        }
2049
2050        // Ensure all properties are marked as required
2051        if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
2052            let all_keys: Vec<serde_json::Value> = props
2053                .keys()
2054                .map(|k| serde_json::Value::String(k.clone()))
2055                .collect();
2056            obj.insert("required".to_owned(), serde_json::Value::Array(all_keys));
2057        }
2058    }
2059
2060    // Recursively process nested schemas
2061    if let Some(props) = obj.get_mut("properties")
2062        && let Some(props_obj) = props.as_object_mut()
2063    {
2064        for prop_schema in props_obj.values_mut() {
2065            fix_schema_for_strict_mode(prop_schema);
2066        }
2067    }
2068
2069    // Process array items
2070    if let Some(items) = obj.get_mut("items") {
2071        fix_schema_for_strict_mode(items);
2072    }
2073
2074    // Process anyOf/oneOf/allOf
2075    for key in ["anyOf", "oneOf", "allOf"] {
2076        if let Some(arr) = obj.get_mut(key)
2077            && let Some(arr_items) = arr.as_array_mut()
2078        {
2079            for item in arr_items {
2080                fix_schema_for_strict_mode(item);
2081            }
2082        }
2083    }
2084}
2085
2086/// Wrap a schema in `anyOf: [{original}, {"type": "null"}]` so the property
2087/// accepts its original type OR null. Appends the null variant when an `anyOf`
2088/// already exists.
2089fn make_nullable(schema: &mut serde_json::Value) {
2090    if let Some(any_of) = schema
2091        .as_object_mut()
2092        .and_then(|o| o.get_mut("anyOf"))
2093        .and_then(|v| v.as_array_mut())
2094    {
2095        let has_null = any_of
2096            .iter()
2097            .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
2098        if !has_null {
2099            any_of.push(serde_json::json!({"type": "null"}));
2100        }
2101        return;
2102    }
2103
2104    let original = schema.clone();
2105    *schema = serde_json::json!({
2106        "anyOf": [original, {"type": "null"}]
2107    });
2108}
2109
2110/// Check whether a JSON schema contains any object-typed schema without a
2111/// `properties` map (a free-form object). These are incompatible with
2112/// `OpenAI` strict mode and must disable it.
2113fn has_freeform_object(schema: &serde_json::Value) -> bool {
2114    let Some(obj) = schema.as_object() else {
2115        return false;
2116    };
2117
2118    let is_object = obj
2119        .get("type")
2120        .is_some_and(|t| t.as_str() == Some("object"));
2121
2122    if is_object && !obj.contains_key("properties") {
2123        return true;
2124    }
2125
2126    if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
2127        for prop in props.values() {
2128            if has_freeform_object(prop) {
2129                return true;
2130            }
2131        }
2132    }
2133
2134    if let Some(items) = obj.get("items")
2135        && has_freeform_object(items)
2136    {
2137        return true;
2138    }
2139
2140    for key in ["anyOf", "oneOf", "allOf"] {
2141        if let Some(arr) = obj.get(key).and_then(|v| v.as_array()) {
2142            for item in arr {
2143                if has_freeform_object(item) {
2144                    return true;
2145                }
2146            }
2147        }
2148    }
2149
2150    false
2151}
2152
2153fn convert_tool(tool: agent_sdk_foundation::llm::Tool) -> ApiTool {
2154    // Strict mode requires additionalProperties: false on all objects and every
2155    // property in required, which is incompatible with free-form object schemas
2156    // (objects with no defined properties). Detect and skip strict for those —
2157    // matching the sibling openai_responses provider.
2158    let mut schema = tool.input_schema;
2159    let use_strict = if has_freeform_object(&schema) {
2160        log::debug!(
2161            "Tool '{}' has free-form object schema — disabling strict mode",
2162            tool.name
2163        );
2164        None
2165    } else {
2166        fix_schema_for_strict_mode(&mut schema);
2167        Some(true)
2168    };
2169
2170    ApiTool {
2171        r#type: "function".to_owned(),
2172        name: tool.name,
2173        description: Some(tool.description),
2174        parameters: Some(schema),
2175        strict: use_strict,
2176    }
2177}
2178
2179fn suggested_filename(media_type: &str) -> String {
2180    match media_type {
2181        "application/pdf" => "attachment.pdf".to_string(),
2182        "image/png" => "image.png".to_string(),
2183        "image/jpeg" => "image.jpg".to_string(),
2184        "image/gif" => "image.gif".to_string(),
2185        "image/webp" => "image.webp".to_string(),
2186        _ => "attachment.bin".to_string(),
2187    }
2188}
2189
2190fn reasoning_output_item(fields: &serde_json::Map<String, serde_json::Value>) -> serde_json::Value {
2191    let mut item = fields.clone();
2192    item.insert(
2193        "type".to_owned(),
2194        serde_json::Value::String("reasoning".to_owned()),
2195    );
2196    serde_json::Value::Object(item)
2197}
2198
2199fn reasoning_summary_texts(fields: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
2200    fields
2201        .get("summary")
2202        .and_then(serde_json::Value::as_array)
2203        .into_iter()
2204        .flatten()
2205        .filter(|summary| {
2206            summary.get("type").and_then(serde_json::Value::as_str) == Some("summary_text")
2207        })
2208        .filter_map(|summary| {
2209            summary
2210                .get("text")
2211                .and_then(serde_json::Value::as_str)
2212                .filter(|text| !text.is_empty())
2213                .map(ToOwned::to_owned)
2214        })
2215        .collect()
2216}
2217
2218fn build_content_blocks(output: &[ApiOutputItem]) -> Vec<ContentBlock> {
2219    let mut blocks = Vec::new();
2220
2221    for item in output {
2222        match item {
2223            ApiOutputItem::Message {
2224                role,
2225                phase,
2226                content,
2227            } => {
2228                blocks.push(ContentBlock::OpaqueReasoning {
2229                    provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2230                    data: message_state_marker(role, phase.as_deref()),
2231                });
2232                for c in content {
2233                    match c {
2234                        ApiOutputContent::Text { text }
2235                        | ApiOutputContent::Refusal { refusal: text }
2236                            if !text.is_empty() =>
2237                        {
2238                            blocks.push(ContentBlock::Text { text: text.clone() });
2239                        }
2240                        ApiOutputContent::Text { .. }
2241                        | ApiOutputContent::Refusal { .. }
2242                        | ApiOutputContent::Unknown => {}
2243                    }
2244                }
2245            }
2246            ApiOutputItem::FunctionCall {
2247                call_id,
2248                name,
2249                arguments,
2250                ..
2251            } => {
2252                let input =
2253                    serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
2254                blocks.push(ContentBlock::ToolUse {
2255                    id: call_id.clone(),
2256                    name: name.clone(),
2257                    input,
2258                    thought_signature: None,
2259                });
2260            }
2261            ApiOutputItem::Reasoning { fields } => {
2262                blocks.push(ContentBlock::OpaqueReasoning {
2263                    provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2264                    data: reasoning_output_item(fields),
2265                });
2266                blocks.extend(reasoning_summary_texts(fields).into_iter().map(|thinking| {
2267                    ContentBlock::Thinking {
2268                        thinking,
2269                        signature: None,
2270                    }
2271                }));
2272            }
2273            ApiOutputItem::Unknown => {
2274                // Skip unknown output types
2275            }
2276        }
2277    }
2278
2279    blocks
2280}
2281
2282fn output_contains_refusal(output: &[ApiOutputItem]) -> bool {
2283    output.iter().any(|item| {
2284        matches!(
2285            item,
2286            ApiOutputItem::Message { content, .. }
2287                if content
2288                    .iter()
2289                    .any(|content| matches!(content, ApiOutputContent::Refusal { .. }))
2290        )
2291    })
2292}
2293
2294fn build_api_reasoning(thinking: Option<&ThinkingConfig>) -> Option<ApiReasoning> {
2295    thinking
2296        .and_then(resolve_reasoning_effort)
2297        .map(|effort| ApiReasoning { effort })
2298}
2299
2300const fn resolve_reasoning_effort(config: &ThinkingConfig) -> Option<ReasoningEffort> {
2301    if let Some(effort) = config.effort {
2302        return Some(map_effort(effort));
2303    }
2304
2305    match &config.mode {
2306        ThinkingMode::Adaptive => None,
2307        ThinkingMode::Enabled { budget_tokens } => Some(map_budget_to_reasoning(*budget_tokens)),
2308    }
2309}
2310
2311const fn map_effort(effort: Effort) -> ReasoningEffort {
2312    match effort {
2313        Effort::Low => ReasoningEffort::Low,
2314        Effort::Medium => ReasoningEffort::Medium,
2315        Effort::High => ReasoningEffort::High,
2316        Effort::Max => ReasoningEffort::XHigh,
2317    }
2318}
2319
2320const fn map_reasoning_effort(effort: ReasoningEffort) -> Effort {
2321    match effort {
2322        ReasoningEffort::Low => Effort::Low,
2323        ReasoningEffort::Medium => Effort::Medium,
2324        ReasoningEffort::High => Effort::High,
2325        ReasoningEffort::XHigh => Effort::Max,
2326    }
2327}
2328
2329const fn map_budget_to_reasoning(budget_tokens: u32) -> ReasoningEffort {
2330    if budget_tokens <= 4_096 {
2331        ReasoningEffort::Low
2332    } else if budget_tokens <= 16_384 {
2333        ReasoningEffort::Medium
2334    } else if budget_tokens <= 32_768 {
2335        ReasoningEffort::High
2336    } else {
2337        ReasoningEffort::XHigh
2338    }
2339}
2340
2341fn codex_url(base_url: &str) -> String {
2342    let normalized = base_url.trim_end_matches('/');
2343    if normalized.ends_with("/codex/responses") {
2344        normalized.to_string()
2345    } else if normalized.ends_with("/codex") {
2346        format!("{normalized}/responses")
2347    } else {
2348        format!("{normalized}/codex/responses")
2349    }
2350}
2351
2352fn codex_websocket_url(base_url: &str) -> Result<url::Url> {
2353    let mut url = url::Url::parse(&codex_url(base_url))
2354        .context("failed to parse OpenAI Codex websocket URL")?;
2355
2356    let scheme = match url.scheme() {
2357        "http" => Some("ws"),
2358        "https" => Some("wss"),
2359        _ => None,
2360    };
2361
2362    if let Some(scheme) = scheme {
2363        let _ = url.set_scheme(scheme);
2364    }
2365
2366    Ok(url)
2367}
2368
2369fn extract_account_id(token: &str) -> Result<String> {
2370    let payload = token
2371        .split('.')
2372        .nth(1)
2373        .ok_or_else(|| anyhow::anyhow!("invalid OpenAI Codex OAuth token"))?;
2374    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
2375        .decode(payload)
2376        .context("failed to decode OpenAI Codex token payload")?;
2377    let payload: serde_json::Value =
2378        serde_json::from_slice(&decoded).context("failed to parse OpenAI Codex token payload")?;
2379    payload
2380        .get(OPENAI_CODEX_JWT_CLAIM_PATH)
2381        .and_then(|value| value.get("chatgpt_account_id"))
2382        .and_then(serde_json::Value::as_str)
2383        .map(ToOwned::to_owned)
2384        .ok_or_else(|| anyhow::anyhow!("chatgpt_account_id missing from OpenAI Codex token"))
2385}
2386
2387fn is_empty(value: &str) -> bool {
2388    value.trim().is_empty()
2389}
2390
2391// ============================================================================
2392// Streaming helpers
2393// ============================================================================
2394
2395struct ToolCallAccumulator {
2396    id: String,
2397    name: String,
2398    arguments: String,
2399    /// Registration order, used to assign deterministic, distinct block indices
2400    /// when emitting (`HashMap` iteration order is otherwise nondeterministic).
2401    order: usize,
2402    /// Responses output-item index, when the stream reports it.
2403    block_index: Option<usize>,
2404}
2405
2406fn usage_from_api_usage(usage: &ApiUsage) -> Usage {
2407    Usage {
2408        input_tokens: usage.input_tokens,
2409        output_tokens: usage.output_tokens,
2410        cached_input_tokens: usage
2411            .input_tokens_details
2412            .as_ref()
2413            .map_or(0, |details| details.cached_tokens),
2414        cache_creation_input_tokens: usage
2415            .input_tokens_details
2416            .as_ref()
2417            .map_or(0, |details| details.cache_write_tokens),
2418    }
2419}
2420
2421fn output_block_index(output_index: Option<usize>) -> usize {
2422    output_index.unwrap_or(0).saturating_mul(2)
2423}
2424
2425fn reasoning_summary_block_index(output_index: Option<usize>) -> usize {
2426    output_block_index(output_index).saturating_add(1)
2427}
2428
2429fn decode_stream_event(data: &str) -> Result<ApiStreamEvent> {
2430    serde_json::from_str(data).with_context(|| {
2431        // Include a bounded snippet of the offending event: SSE payloads
2432        // carry no credentials, and "invalid stream event" without the
2433        // event is undiagnosable in the field (2026-07-10, GPT-5.6 rollout).
2434        let mut snippet: String = data.chars().take(512).collect();
2435        if data.chars().count() > 512 {
2436            snippet.push('…');
2437        }
2438        format!("invalid OpenAI Codex Responses stream event: {snippet}")
2439    })
2440}
2441
2442fn decode_output_item(item: Option<serde_json::Value>) -> Result<ApiOutputItem> {
2443    let item = item.context("OpenAI Codex output_item.done omitted item")?;
2444    serde_json::from_value(item).context("invalid OpenAI Codex output item")
2445}
2446
2447fn output_item_stream_deltas(
2448    item: &ApiOutputItem,
2449    output_index: usize,
2450    include_summary: bool,
2451) -> Vec<StreamDelta> {
2452    let block_index = output_index.saturating_mul(2);
2453    match item {
2454        ApiOutputItem::Message { role, phase, .. } => {
2455            vec![StreamDelta::OpaqueReasoning {
2456                provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2457                data: message_state_marker(role, phase.as_deref()),
2458                block_index,
2459            }]
2460        }
2461        ApiOutputItem::Reasoning { fields } => {
2462            let mut deltas = vec![StreamDelta::OpaqueReasoning {
2463                provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2464                data: reasoning_output_item(fields),
2465                block_index,
2466            }];
2467            if include_summary {
2468                let summary_block_index = block_index.saturating_add(1);
2469                deltas.extend(reasoning_summary_texts(fields).into_iter().map(|delta| {
2470                    StreamDelta::ThinkingDelta {
2471                        delta,
2472                        block_index: summary_block_index,
2473                    }
2474                }));
2475            }
2476            deltas
2477        }
2478        ApiOutputItem::FunctionCall { .. } | ApiOutputItem::Unknown => Vec::new(),
2479    }
2480}
2481
2482fn emit_accumulated_tool_calls(
2483    tool_calls: &HashMap<String, ToolCallAccumulator>,
2484) -> Vec<StreamDelta> {
2485    // Assign distinct, monotonically increasing block indices in registration
2486    // order. The previous code gave every call the same index (1) and iterated
2487    // HashMap::values(), so StreamAccumulator's stable sort preserved
2488    // nondeterministic insertion order for multi-tool turns.
2489    let mut accs: Vec<&ToolCallAccumulator> = tool_calls.values().collect();
2490    accs.sort_by_key(|acc| (acc.block_index.unwrap_or(usize::MAX), acc.order));
2491
2492    let mut deltas = Vec::with_capacity(accs.len() * 2);
2493    for (idx, acc) in accs.iter().enumerate() {
2494        let block_index = acc
2495            .block_index
2496            .unwrap_or_else(|| idx.saturating_add(1).saturating_mul(2));
2497        deltas.push(StreamDelta::ToolUseStart {
2498            id: acc.id.clone(),
2499            name: acc.name.clone(),
2500            block_index,
2501            thought_signature: None,
2502        });
2503        deltas.push(StreamDelta::ToolInputDelta {
2504            id: acc.id.clone(),
2505            delta: acc.arguments.clone(),
2506            block_index,
2507        });
2508    }
2509    deltas
2510}
2511
2512fn stop_reason_from_stream_state(
2513    tool_calls: &HashMap<String, ToolCallAccumulator>,
2514    status: Option<ApiStatus>,
2515    refused: bool,
2516    incomplete_reason: Option<&str>,
2517) -> Option<StopReason> {
2518    let status = status?;
2519    Some(match status {
2520        ApiStatus::Incomplete => {
2521            incomplete_reason.map_or(StopReason::Unknown, incomplete_stop_reason)
2522        }
2523        ApiStatus::Completed if refused => StopReason::Refusal,
2524        ApiStatus::Completed if !tool_calls.is_empty() => StopReason::ToolUse,
2525        ApiStatus::Completed => StopReason::EndTurn,
2526        // Failed, a non-terminal status leaking into the final state, or a
2527        // status this build does not know: same Unknown as the old Failed
2528        // arm. Spelled out per variant so adding a status forces a
2529        // decision here.
2530        ApiStatus::Failed
2531        | ApiStatus::InProgress
2532        | ApiStatus::Queued
2533        | ApiStatus::Cancelled
2534        | ApiStatus::Other => StopReason::Unknown,
2535    })
2536}
2537
2538fn incomplete_stop_reason(reason: &str) -> StopReason {
2539    match reason {
2540        "max_output_tokens" => StopReason::MaxTokens,
2541        "content_filter" => StopReason::Refusal,
2542        "model_context_window_exceeded" => StopReason::ModelContextWindowExceeded,
2543        _ => StopReason::Unknown,
2544    }
2545}
2546
2547fn reset_websocket_connection(session: &mut WebsocketSessionState) {
2548    session.connection = None;
2549    if session.prewarmed {
2550        session.last_request = None;
2551        session.last_response_id = None;
2552        session.last_response_items.clear();
2553    }
2554    session.prewarmed = false;
2555}
2556
2557/// Bound the websocket-session map by evicting idle (not in-flight) sessions,
2558/// oldest-first by last use. The map exists for cross-turn reuse, so completed
2559/// sessions retain a cached baseline indefinitely; without eviction a host
2560/// serving many distinct sessions would leak memory and open sockets without
2561/// bound. Sessions whose lock is currently held (in use) or that are mid-turn
2562/// are retained.
2563fn evict_idle_sessions(sessions: &mut HashMap<String, Arc<Mutex<WebsocketSessionState>>>) {
2564    let mut candidates: Vec<(String, Option<Instant>)> = Vec::new();
2565    for (key, state) in sessions.iter() {
2566        if let Ok(guard) = state.try_lock()
2567            && !guard.in_flight
2568        {
2569            candidates.push((key.clone(), guard.last_used));
2570        }
2571    }
2572    // Oldest first; `None` (never used) sorts before `Some`.
2573    candidates.sort_by_key(|a| a.1);
2574    let evict_count = candidates.len().min(sessions.len() / 2 + 1);
2575    for (key, _) in candidates.into_iter().take(evict_count) {
2576        sessions.remove(&key);
2577    }
2578}
2579
2580fn parse_wrapped_websocket_error_event(payload: &str) -> Option<(StatusCode, String)> {
2581    let event: ApiWrappedWebsocketErrorEvent = serde_json::from_str(payload).ok()?;
2582    if event.kind != "error" {
2583        return None;
2584    }
2585
2586    if event.error.as_ref().and_then(|error| error.code.as_deref())
2587        == Some(OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE)
2588    {
2589        let message = event
2590            .error
2591            .and_then(|error| error.message)
2592            .unwrap_or_else(|| "Responses websocket connection limit reached".to_string());
2593        return Some((StatusCode::TOO_MANY_REQUESTS, message));
2594    }
2595
2596    let status = StatusCode::from_u16(event.status?).ok()?;
2597    let message = event
2598        .error
2599        .and_then(|error| error.message)
2600        .unwrap_or_else(|| payload.to_string());
2601    if status.is_success() {
2602        None
2603    } else {
2604        Some((status, message))
2605    }
2606}
2607
2608fn output_item_to_input_item(item: ApiOutputItem) -> Option<ApiInputItem> {
2609    match item {
2610        ApiOutputItem::Message {
2611            role,
2612            phase,
2613            content,
2614        } => {
2615            let role = if role == "user" {
2616                ApiRole::User
2617            } else {
2618                ApiRole::Assistant
2619            };
2620            let parts: Vec<ApiInputContent> = content
2621                .into_iter()
2622                .filter_map(|content| match content {
2623                    ApiOutputContent::Text { text }
2624                    | ApiOutputContent::Refusal { refusal: text }
2625                        if !text.is_empty() =>
2626                    {
2627                        Some(ApiInputContent::OutputText { text })
2628                    }
2629                    ApiOutputContent::Unknown
2630                    | ApiOutputContent::Text { .. }
2631                    | ApiOutputContent::Refusal { .. } => None,
2632                })
2633                .collect();
2634            if parts.is_empty() {
2635                None
2636            } else {
2637                Some(ApiInputItem::Message(ApiMessage {
2638                    role,
2639                    content: ApiMessageContent::Parts(parts),
2640                    phase,
2641                }))
2642            }
2643        }
2644        ApiOutputItem::FunctionCall {
2645            call_id,
2646            name,
2647            arguments,
2648        } => Some(ApiInputItem::FunctionCall(ApiFunctionCall::new(
2649            call_id, name, arguments,
2650        ))),
2651        ApiOutputItem::Reasoning { fields } => Some(ApiInputItem::OpaqueReasoning(
2652            reasoning_output_item(&fields),
2653        )),
2654        ApiOutputItem::Unknown => None,
2655    }
2656}
2657
2658fn prepare_websocket_request(
2659    request: &ApiStreamingRequest,
2660    session: &WebsocketSessionState,
2661    allow_empty_delta: bool,
2662) -> ApiWebsocketRequest {
2663    let mut websocket_request = ApiWebsocketRequest::from(request);
2664
2665    let Some(last_request) = session.last_request.as_ref() else {
2666        return websocket_request;
2667    };
2668    let Some(last_response_id) = session.last_response_id.as_ref() else {
2669        return websocket_request;
2670    };
2671
2672    let mut previous_without_input = last_request.clone();
2673    previous_without_input.input.clear();
2674    let mut current_without_input = request.clone();
2675    current_without_input.input.clear();
2676    if previous_without_input != current_without_input {
2677        return websocket_request;
2678    }
2679
2680    let mut baseline = last_request.input.clone();
2681    baseline.extend(session.last_response_items.clone());
2682    if request.input.starts_with(&baseline)
2683        && (allow_empty_delta || baseline.len() < request.input.len())
2684    {
2685        websocket_request.previous_response_id = Some(last_response_id.clone());
2686        websocket_request.input = request.input[baseline.len()..].to_vec();
2687    }
2688
2689    websocket_request
2690}
2691
2692// ============================================================================
2693// API Request Types
2694// ============================================================================
2695
2696#[derive(Serialize)]
2697struct ApiResponsesRequest<'a> {
2698    model: &'a str,
2699    #[serde(skip_serializing_if = "is_empty")]
2700    instructions: &'a str,
2701    input: &'a [ApiInputItem],
2702    #[serde(skip_serializing_if = "Option::is_none")]
2703    tools: Option<&'a [ApiTool]>,
2704    #[serde(skip_serializing_if = "Option::is_none")]
2705    max_output_tokens: Option<u32>,
2706    #[serde(skip_serializing_if = "Option::is_none")]
2707    reasoning: Option<ApiReasoning>,
2708    #[serde(skip_serializing_if = "Option::is_none")]
2709    tool_choice: Option<ApiToolChoice>,
2710    #[serde(skip_serializing_if = "Option::is_none")]
2711    parallel_tool_calls: Option<bool>,
2712    store: bool,
2713    #[serde(skip_serializing_if = "Option::is_none")]
2714    text: Option<ApiTextSettings>,
2715    #[serde(skip_serializing_if = "Option::is_none")]
2716    include: Option<&'a [&'static str]>,
2717    #[serde(skip_serializing_if = "Option::is_none")]
2718    prompt_cache_key: Option<&'a str>,
2719}
2720
2721#[derive(Clone, PartialEq, Serialize)]
2722struct ApiStreamingRequest {
2723    model: String,
2724    #[serde(skip_serializing_if = "String::is_empty")]
2725    instructions: String,
2726    input: Vec<ApiInputItem>,
2727    #[serde(skip_serializing_if = "Option::is_none")]
2728    tools: Option<Vec<ApiTool>>,
2729    #[serde(skip_serializing_if = "Option::is_none")]
2730    max_output_tokens: Option<u32>,
2731    #[serde(skip_serializing_if = "Option::is_none")]
2732    reasoning: Option<ApiReasoning>,
2733    #[serde(skip_serializing_if = "Option::is_none")]
2734    tool_choice: Option<ApiToolChoice>,
2735    #[serde(skip_serializing_if = "Option::is_none")]
2736    parallel_tool_calls: Option<bool>,
2737    store: bool,
2738    #[serde(skip_serializing_if = "Option::is_none")]
2739    text: Option<ApiTextSettings>,
2740    #[serde(skip_serializing_if = "Option::is_none")]
2741    include: Option<Vec<String>>,
2742    #[serde(skip_serializing_if = "Option::is_none")]
2743    prompt_cache_key: Option<String>,
2744    stream: bool,
2745}
2746
2747#[derive(Clone, Serialize)]
2748struct ApiWebsocketRequest {
2749    #[serde(rename = "type")]
2750    kind: &'static str,
2751    model: String,
2752    #[serde(skip_serializing_if = "String::is_empty")]
2753    instructions: String,
2754    #[serde(skip_serializing_if = "Option::is_none")]
2755    previous_response_id: Option<String>,
2756    input: Vec<ApiInputItem>,
2757    #[serde(skip_serializing_if = "Option::is_none")]
2758    tools: Option<Vec<ApiTool>>,
2759    #[serde(skip_serializing_if = "Option::is_none")]
2760    max_output_tokens: Option<u32>,
2761    #[serde(skip_serializing_if = "Option::is_none")]
2762    reasoning: Option<ApiReasoning>,
2763    #[serde(skip_serializing_if = "Option::is_none")]
2764    tool_choice: Option<ApiToolChoice>,
2765    #[serde(skip_serializing_if = "Option::is_none")]
2766    parallel_tool_calls: Option<bool>,
2767    store: bool,
2768    #[serde(skip_serializing_if = "Option::is_none")]
2769    text: Option<ApiTextSettings>,
2770    #[serde(skip_serializing_if = "Option::is_none")]
2771    include: Option<Vec<String>>,
2772    #[serde(skip_serializing_if = "Option::is_none")]
2773    prompt_cache_key: Option<String>,
2774    stream: bool,
2775    #[serde(skip_serializing_if = "Option::is_none")]
2776    generate: Option<bool>,
2777}
2778
2779impl From<&ApiStreamingRequest> for ApiWebsocketRequest {
2780    fn from(request: &ApiStreamingRequest) -> Self {
2781        Self {
2782            kind: "response.create",
2783            model: request.model.clone(),
2784            instructions: request.instructions.clone(),
2785            previous_response_id: None,
2786            input: request.input.clone(),
2787            tools: request.tools.clone(),
2788            max_output_tokens: request.max_output_tokens,
2789            reasoning: request.reasoning.clone(),
2790            tool_choice: request.tool_choice.clone(),
2791            parallel_tool_calls: request.parallel_tool_calls,
2792            store: request.store,
2793            text: request.text.clone(),
2794            include: request.include.clone(),
2795            prompt_cache_key: request.prompt_cache_key.clone(),
2796            stream: request.stream,
2797            generate: None,
2798        }
2799    }
2800}
2801
2802#[derive(Clone, PartialEq, Serialize)]
2803struct ApiTextSettings {
2804    verbosity: &'static str,
2805    /// Structured-output schema (`text.format`), set when the request carries a
2806    /// `response_format`.
2807    #[serde(skip_serializing_if = "Option::is_none")]
2808    format: Option<ApiResponseTextFormat>,
2809}
2810
2811#[derive(Clone, PartialEq, Serialize)]
2812struct ApiResponseTextFormat {
2813    #[serde(rename = "type")]
2814    format_type: &'static str,
2815    name: String,
2816    schema: serde_json::Value,
2817    strict: bool,
2818}
2819
2820impl From<&ResponseFormat> for ApiResponseTextFormat {
2821    fn from(rf: &ResponseFormat) -> Self {
2822        Self {
2823            format_type: "json_schema",
2824            name: rf.name.clone(),
2825            schema: rf.schema.clone(),
2826            strict: rf.strict,
2827        }
2828    }
2829}
2830
2831/// Responses API `tool_choice` wire format.
2832///
2833/// - `"auto"` — model decides (the Codex default).
2834/// - `{"type": "function", "name": "<name>"}` — force a specific function.
2835#[derive(Clone, PartialEq, Serialize)]
2836#[serde(untagged)]
2837enum ApiToolChoice {
2838    Mode(&'static str),
2839    Function {
2840        #[serde(rename = "type")]
2841        choice_type: &'static str,
2842        name: String,
2843    },
2844}
2845
2846/// Map an optional [`ToolChoice`] onto the Codex wire `tool_choice`, defaulting
2847/// to `"auto"` (the historical Codex behavior) when unset.
2848fn codex_tool_choice(tool_choice: Option<&ToolChoice>) -> ApiToolChoice {
2849    match tool_choice {
2850        Some(ToolChoice::Tool(name)) => ApiToolChoice::Function {
2851            choice_type: "function",
2852            name: name.clone(),
2853        },
2854        _ => ApiToolChoice::Mode("auto"),
2855    }
2856}
2857
2858#[derive(Clone, PartialEq, Serialize)]
2859struct ApiReasoning {
2860    effort: ReasoningEffort,
2861}
2862
2863#[derive(Clone, PartialEq, Serialize)]
2864#[serde(untagged)]
2865enum ApiInputItem {
2866    Message(ApiMessage),
2867    FunctionCall(ApiFunctionCall),
2868    FunctionCallOutput(ApiFunctionCallOutput),
2869    OpaqueReasoning(serde_json::Value),
2870}
2871
2872#[derive(Clone, PartialEq, Serialize)]
2873struct ApiMessage {
2874    role: ApiRole,
2875    content: ApiMessageContent,
2876    #[serde(skip_serializing_if = "Option::is_none")]
2877    phase: Option<String>,
2878}
2879
2880#[derive(Clone, Copy, PartialEq, Serialize)]
2881#[serde(rename_all = "lowercase")]
2882enum ApiRole {
2883    User,
2884    Assistant,
2885}
2886
2887#[derive(Clone, PartialEq, Serialize)]
2888#[serde(untagged)]
2889enum ApiMessageContent {
2890    Text(String),
2891    Parts(Vec<ApiInputContent>),
2892}
2893
2894#[derive(Clone, PartialEq, Serialize)]
2895#[serde(tag = "type")]
2896enum ApiInputContent {
2897    #[serde(rename = "input_text")]
2898    InputText { text: String },
2899    #[serde(rename = "output_text")]
2900    OutputText { text: String },
2901    #[serde(rename = "input_image")]
2902    Image { image_url: String },
2903    #[serde(rename = "input_file")]
2904    File { filename: String, file_data: String },
2905}
2906
2907#[derive(Clone, PartialEq, Serialize)]
2908struct ApiFunctionCall {
2909    r#type: &'static str,
2910    call_id: String,
2911    name: String,
2912    arguments: String,
2913}
2914
2915impl ApiFunctionCall {
2916    const fn new(call_id: String, name: String, arguments: String) -> Self {
2917        Self {
2918            r#type: "function_call",
2919            call_id,
2920            name,
2921            arguments,
2922        }
2923    }
2924}
2925
2926#[derive(Clone, PartialEq, Serialize)]
2927struct ApiFunctionCallOutput {
2928    r#type: &'static str,
2929    call_id: String,
2930    output: String,
2931}
2932
2933impl ApiFunctionCallOutput {
2934    const fn new(call_id: String, output: String) -> Self {
2935        Self {
2936            r#type: "function_call_output",
2937            call_id,
2938            output,
2939        }
2940    }
2941}
2942
2943#[derive(Clone, PartialEq, Serialize)]
2944struct ApiTool {
2945    r#type: String,
2946    name: String,
2947    #[serde(skip_serializing_if = "Option::is_none")]
2948    description: Option<String>,
2949    #[serde(skip_serializing_if = "Option::is_none")]
2950    parameters: Option<serde_json::Value>,
2951    #[serde(skip_serializing_if = "Option::is_none")]
2952    strict: Option<bool>,
2953}
2954
2955// ============================================================================
2956// API Response Types
2957// ============================================================================
2958
2959#[derive(Deserialize)]
2960struct ApiResponse {
2961    id: String,
2962    model: String,
2963    output: Vec<ApiOutputItem>,
2964    #[serde(default)]
2965    status: Option<ApiStatus>,
2966    #[serde(default)]
2967    usage: Option<ApiUsage>,
2968    #[serde(default)]
2969    error: Option<ApiErrorBody>,
2970    #[serde(default)]
2971    incomplete_details: Option<ApiIncompleteDetails>,
2972}
2973
2974#[derive(Deserialize)]
2975struct ApiIncompleteDetails {
2976    #[serde(default)]
2977    reason: Option<String>,
2978}
2979
2980#[derive(Clone, Copy, Deserialize)]
2981#[serde(rename_all = "snake_case")]
2982enum ApiStatus {
2983    Completed,
2984    Incomplete,
2985    Failed,
2986    // Non-terminal statuses ride lifecycle events (`response.created`
2987    // streams `"status":"in_progress"` on the GPT-5.6 ChatGPT backend,
2988    // observed live 2026-07-10) — they must parse without killing the
2989    // stream even though no consumer branches on them.
2990    InProgress,
2991    Queued,
2992    Cancelled,
2993    // Forward-compat: a status this build does not know is not a broken
2994    // stream. Consumers treat it like the existing non-completed arms.
2995    #[serde(other)]
2996    Other,
2997}
2998
2999#[derive(Deserialize)]
3000struct ApiUsage {
3001    input_tokens: u32,
3002    output_tokens: u32,
3003    #[serde(default)]
3004    input_tokens_details: Option<ApiInputTokensDetails>,
3005}
3006
3007#[derive(Deserialize)]
3008struct ApiInputTokensDetails {
3009    #[serde(default)]
3010    cached_tokens: u32,
3011    #[serde(default)]
3012    cache_write_tokens: u32,
3013}
3014
3015#[derive(Deserialize)]
3016#[serde(tag = "type")]
3017enum ApiOutputItem {
3018    #[serde(rename = "message")]
3019    Message {
3020        role: String,
3021        #[serde(default)]
3022        phase: Option<String>,
3023        content: Vec<ApiOutputContent>,
3024    },
3025    #[serde(rename = "function_call")]
3026    FunctionCall {
3027        call_id: String,
3028        name: String,
3029        arguments: String,
3030    },
3031    #[serde(rename = "reasoning")]
3032    Reasoning {
3033        #[serde(flatten)]
3034        fields: serde_json::Map<String, serde_json::Value>,
3035    },
3036    #[serde(other)]
3037    Unknown,
3038}
3039
3040#[derive(Deserialize)]
3041#[serde(tag = "type")]
3042enum ApiOutputContent {
3043    #[serde(rename = "output_text")]
3044    Text { text: String },
3045    #[serde(rename = "refusal")]
3046    Refusal { refusal: String },
3047    #[serde(other)]
3048    Unknown,
3049}
3050
3051// ============================================================================
3052// Streaming Types
3053// ============================================================================
3054
3055#[derive(Deserialize)]
3056struct ApiStreamEvent {
3057    r#type: String,
3058    #[serde(default)]
3059    output_index: Option<usize>,
3060    #[serde(default)]
3061    delta: Option<String>,
3062    #[serde(default)]
3063    call_id: Option<String>,
3064    #[serde(default)]
3065    name: Option<String>,
3066    #[serde(default)]
3067    item: Option<serde_json::Value>,
3068    #[serde(default)]
3069    response: Option<ApiStreamResponse>,
3070}
3071
3072#[derive(Deserialize)]
3073struct ApiStreamResponse {
3074    #[serde(default)]
3075    id: Option<String>,
3076    #[serde(default)]
3077    usage: Option<ApiUsage>,
3078    #[serde(default)]
3079    error: Option<ApiErrorBody>,
3080    #[serde(default)]
3081    status: Option<ApiStatus>,
3082    #[serde(default)]
3083    incomplete_details: Option<ApiIncompleteDetails>,
3084}
3085
3086#[derive(Deserialize)]
3087struct ApiErrorBody {
3088    #[serde(default)]
3089    message: Option<String>,
3090}
3091
3092#[derive(Deserialize)]
3093struct ApiWrappedWebsocketErrorBody {
3094    #[serde(default)]
3095    code: Option<String>,
3096    #[serde(default)]
3097    message: Option<String>,
3098}
3099
3100#[derive(Deserialize)]
3101struct ApiWrappedWebsocketErrorEvent {
3102    #[serde(rename = "type")]
3103    kind: String,
3104    #[serde(alias = "status_code")]
3105    status: Option<u16>,
3106    #[serde(default)]
3107    error: Option<ApiWrappedWebsocketErrorBody>,
3108}
3109
3110// ============================================================================
3111// Tests
3112// ============================================================================
3113
3114#[cfg(test)]
3115mod tests {
3116    use super::*;
3117
3118    #[test]
3119    fn test_model_constant() {
3120        assert_eq!(MODEL_GPT54, "gpt-5.4");
3121        assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
3122        assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
3123    }
3124
3125    #[test]
3126    fn test_codex_factory() {
3127        let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
3128        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
3129        assert_eq!(provider.provider(), "openai-codex");
3130    }
3131
3132    #[test]
3133    fn test_gpt54_factory() {
3134        let provider = OpenAICodexResponsesProvider::gpt54("test-key".to_string());
3135        assert_eq!(provider.model(), MODEL_GPT54);
3136        assert_eq!(provider.provider(), "openai-codex");
3137    }
3138
3139    #[test]
3140    fn test_gpt53_codex_factory() {
3141        let provider = OpenAICodexResponsesProvider::gpt53_codex("test-key".to_string());
3142        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
3143        assert_eq!(provider.provider(), "openai-codex");
3144    }
3145
3146    #[test]
3147    fn test_reasoning_effort_serialization() {
3148        let low = serde_json::to_string(&ReasoningEffort::Low).unwrap();
3149        assert_eq!(low, "\"low\"");
3150
3151        let xhigh = serde_json::to_string(&ReasoningEffort::XHigh).unwrap();
3152        assert_eq!(xhigh, "\"xhigh\"");
3153    }
3154
3155    #[test]
3156    fn test_with_reasoning_effort() {
3157        let provider = OpenAICodexResponsesProvider::codex("test-key".to_string())
3158            .with_reasoning_effort(ReasoningEffort::High);
3159        let thinking = provider.thinking.as_ref().unwrap();
3160        assert!(matches!(thinking.effort, Some(Effort::High)));
3161    }
3162
3163    #[test]
3164    fn test_build_api_reasoning_uses_explicit_effort() {
3165        let reasoning =
3166            build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::Low))).unwrap();
3167        assert!(matches!(reasoning.effort, ReasoningEffort::Low));
3168    }
3169
3170    #[test]
3171    fn test_build_api_reasoning_omits_adaptive_without_effort() {
3172        assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
3173    }
3174
3175    #[test]
3176    fn test_openai_responses_accepts_adaptive_thinking() {
3177        let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
3178        assert!(
3179            provider
3180                .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
3181                .is_ok()
3182        );
3183    }
3184
3185    #[test]
3186    fn test_api_tool_serialization() {
3187        let tool = ApiTool {
3188            r#type: "function".to_owned(),
3189            name: "get_weather".to_owned(),
3190            description: Some("Get weather".to_owned()),
3191            parameters: Some(serde_json::json!({"type": "object"})),
3192            strict: Some(true),
3193        };
3194
3195        let json = serde_json::to_string(&tool).unwrap();
3196        assert!(json.contains("\"type\":\"function\""));
3197        assert!(json.contains("\"name\":\"get_weather\""));
3198        assert!(json.contains("\"strict\":true"));
3199    }
3200
3201    fn test_token() -> String {
3202        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
3203        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(
3204            r#"{{"{OPENAI_CODEX_JWT_CLAIM_PATH}":{{"chatgpt_account_id":"acct_123"}}}}"#
3205        ));
3206        format!("{header}.{payload}.sig")
3207    }
3208
3209    #[test]
3210    fn test_build_headers_match_codex_style_defaults() -> anyhow::Result<()> {
3211        let provider = OpenAICodexResponsesProvider::codex(test_token());
3212
3213        let headers = provider.build_headers(true, Some("session-123"), None)?;
3214        assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
3215        assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
3216        assert_eq!(headers.get("session_id").unwrap(), "session-123");
3217        assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
3218        assert_eq!(
3219            headers.get("OpenAI-Beta").unwrap(),
3220            OPENAI_CODEX_RESPONSES_BETA_HEADER
3221        );
3222
3223        Ok(())
3224    }
3225
3226    #[test]
3227    fn test_build_websocket_headers_match_codex_style_defaults() -> anyhow::Result<()> {
3228        let provider = OpenAICodexResponsesProvider::codex(test_token());
3229
3230        let headers = provider.build_websocket_headers(Some("session-123"), Some("turn-1"))?;
3231        assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
3232        assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
3233        assert_eq!(headers.get("session_id").unwrap(), "session-123");
3234        assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
3235        assert_eq!(
3236            headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
3237            "turn-1"
3238        );
3239        assert_eq!(
3240            headers.get("OpenAI-Beta").unwrap(),
3241            OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER,
3242        );
3243
3244        Ok(())
3245    }
3246
3247    #[test]
3248    fn test_build_headers_uses_configured_account_id_without_jwt_decode() -> anyhow::Result<()> {
3249        let provider = OpenAICodexResponsesProvider::codex("not-a-jwt".to_string())
3250            .with_account_id("acct_stored");
3251
3252        let headers = provider.build_headers(true, Some("session-123"), Some("turn-1"))?;
3253        assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_stored");
3254        assert_eq!(
3255            headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
3256            "turn-1"
3257        );
3258
3259        Ok(())
3260    }
3261
3262    #[test]
3263    fn test_request_serialization_includes_store_false() {
3264        let request = ApiStreamingRequest {
3265            model: MODEL_GPT53_CODEX.to_string(),
3266            instructions: "system".to_string(),
3267            input: Vec::new(),
3268            tools: None,
3269            max_output_tokens: None,
3270            reasoning: None,
3271            tool_choice: Some(ApiToolChoice::Mode("auto")),
3272            parallel_tool_calls: Some(true),
3273            store: false,
3274            text: Some(ApiTextSettings {
3275                verbosity: "medium",
3276                format: None,
3277            }),
3278            include: Some(vec!["reasoning.encrypted_content".to_string()]),
3279            prompt_cache_key: Some("session-123".to_string()),
3280            stream: true,
3281        };
3282
3283        let json = serde_json::to_string(&request).unwrap();
3284        assert!(json.contains("\"store\":false"));
3285        assert!(json.contains("\"stream\":true"));
3286    }
3287
3288    #[test]
3289    fn test_prepare_websocket_request_uses_previous_response_id_for_incremental_input() {
3290        let request = ApiStreamingRequest {
3291            model: MODEL_GPT53_CODEX.to_string(),
3292            instructions: "system".to_string(),
3293            input: vec![
3294                ApiInputItem::Message(ApiMessage {
3295                    role: ApiRole::User,
3296                    content: ApiMessageContent::Text("first".to_string()),
3297                    phase: None,
3298                }),
3299                ApiInputItem::Message(ApiMessage {
3300                    role: ApiRole::Assistant,
3301                    content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3302                        text: "answer".to_string(),
3303                    }]),
3304                    phase: Some("final_answer".to_owned()),
3305                }),
3306                ApiInputItem::Message(ApiMessage {
3307                    role: ApiRole::User,
3308                    content: ApiMessageContent::Text("follow up".to_string()),
3309                    phase: None,
3310                }),
3311            ],
3312            tools: None,
3313            max_output_tokens: None,
3314            reasoning: None,
3315            tool_choice: Some(ApiToolChoice::Mode("auto")),
3316            parallel_tool_calls: None,
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("thread-1".to_string()),
3324            stream: true,
3325        };
3326        let previous_request = ApiStreamingRequest {
3327            input: vec![ApiInputItem::Message(ApiMessage {
3328                role: ApiRole::User,
3329                content: ApiMessageContent::Text("first".to_string()),
3330                phase: None,
3331            })],
3332            ..request.clone()
3333        };
3334        let session = WebsocketSessionState {
3335            connection: None,
3336            last_request: Some(previous_request),
3337            last_response_id: Some("resp_prev".to_string()),
3338            last_response_items: vec![ApiInputItem::Message(ApiMessage {
3339                role: ApiRole::Assistant,
3340                content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3341                    text: "answer".to_string(),
3342                }]),
3343                phase: Some("final_answer".to_owned()),
3344            })],
3345            turn_state: None,
3346            prewarmed: false,
3347            websocket_disabled: false,
3348            in_flight: false,
3349            last_used: None,
3350        };
3351
3352        let websocket_request = prepare_websocket_request(&request, &session, false);
3353        assert_eq!(
3354            websocket_request.previous_response_id.as_deref(),
3355            Some("resp_prev")
3356        );
3357        assert_eq!(websocket_request.input.len(), 1);
3358        match &websocket_request.input[0] {
3359            ApiInputItem::Message(ApiMessage {
3360                role: ApiRole::User,
3361                content: ApiMessageContent::Text(text),
3362                ..
3363            }) => assert_eq!(text, "follow up"),
3364            _ => panic!("expected incremental follow-up user message"),
3365        }
3366    }
3367
3368    #[test]
3369    fn test_parse_wrapped_websocket_error_event_maps_http_status() {
3370        let payload = r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
3371        let parsed = parse_wrapped_websocket_error_event(payload);
3372        assert_eq!(
3373            parsed,
3374            Some((StatusCode::UNAUTHORIZED, "unauthorized".to_string())),
3375        );
3376    }
3377
3378    #[test]
3379    fn test_parse_wrapped_websocket_error_event_maps_connection_limit() {
3380        let payload = format!(
3381            r#"{{"type":"error","status":429,"error":{{"code":"{OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE}","message":"limit"}}}}"#,
3382        );
3383        let parsed = parse_wrapped_websocket_error_event(&payload);
3384        assert_eq!(
3385            parsed,
3386            Some((StatusCode::TOO_MANY_REQUESTS, "limit".to_string())),
3387        );
3388    }
3389
3390    #[test]
3391    fn test_prepare_websocket_request_allows_empty_delta_after_prewarm() {
3392        let request = ApiStreamingRequest {
3393            model: MODEL_GPT53_CODEX.to_string(),
3394            instructions: "system".to_string(),
3395            input: vec![ApiInputItem::Message(ApiMessage {
3396                role: ApiRole::User,
3397                content: ApiMessageContent::Text("first".to_string()),
3398                phase: None,
3399            })],
3400            tools: None,
3401            max_output_tokens: None,
3402            reasoning: None,
3403            tool_choice: Some(ApiToolChoice::Mode("auto")),
3404            parallel_tool_calls: None,
3405            store: false,
3406            text: Some(ApiTextSettings {
3407                verbosity: "medium",
3408                format: None,
3409            }),
3410            include: Some(vec!["reasoning.encrypted_content".to_string()]),
3411            prompt_cache_key: Some("thread-1".to_string()),
3412            stream: true,
3413        };
3414        let session = WebsocketSessionState {
3415            connection: None,
3416            last_request: Some(request.clone()),
3417            last_response_id: Some("resp_prewarm".to_string()),
3418            last_response_items: Vec::new(),
3419            turn_state: None,
3420            prewarmed: true,
3421            websocket_disabled: false,
3422            in_flight: false,
3423            last_used: None,
3424        };
3425
3426        let websocket_request = prepare_websocket_request(&request, &session, true);
3427        assert_eq!(
3428            websocket_request.previous_response_id.as_deref(),
3429            Some("resp_prewarm")
3430        );
3431        assert!(websocket_request.input.is_empty());
3432    }
3433
3434    #[test]
3435    fn test_api_response_deserialization() {
3436        let json = r#"{
3437            "id": "resp_123",
3438            "model": "gpt-5.2-codex",
3439            "output": [
3440                {
3441                    "type": "message",
3442                    "role": "assistant",
3443                    "content": [
3444                        {"type": "output_text", "text": "Hello!"}
3445                    ]
3446                }
3447            ],
3448            "status": "completed",
3449            "usage": {
3450                "input_tokens": 100,
3451                "output_tokens": 50
3452            }
3453        }"#;
3454
3455        let response: ApiResponse = serde_json::from_str(json).unwrap();
3456        assert_eq!(response.id, "resp_123");
3457        assert_eq!(response.model, "gpt-5.2-codex");
3458        assert_eq!(response.output.len(), 1);
3459    }
3460
3461    #[test]
3462    fn test_api_response_with_function_call() {
3463        let json = r#"{
3464            "id": "resp_456",
3465            "model": "gpt-5.2-codex",
3466            "output": [
3467                {
3468                    "type": "function_call",
3469                    "call_id": "call_abc",
3470                    "name": "read_file",
3471                    "arguments": "{\"path\": \"test.txt\"}"
3472                }
3473            ],
3474            "status": "completed"
3475        }"#;
3476
3477        let response: ApiResponse = serde_json::from_str(json).unwrap();
3478        assert_eq!(response.output.len(), 1);
3479
3480        match &response.output[0] {
3481            ApiOutputItem::FunctionCall {
3482                call_id,
3483                name,
3484                arguments,
3485            } => {
3486                assert_eq!(call_id, "call_abc");
3487                assert_eq!(name, "read_file");
3488                assert!(arguments.contains("test.txt"));
3489            }
3490            _ => panic!("Expected FunctionCall"),
3491        }
3492    }
3493
3494    #[test]
3495    fn test_build_api_input_uses_responses_text_types_by_role() {
3496        let request = ChatRequest {
3497            system: "system".to_string(),
3498            messages: vec![
3499                agent_sdk_foundation::llm::Message::user_with_content(vec![ContentBlock::Text {
3500                    text: "question".to_string(),
3501                }]),
3502                agent_sdk_foundation::llm::Message {
3503                    role: agent_sdk_foundation::llm::Role::Assistant,
3504                    content: Content::Blocks(vec![ContentBlock::Text {
3505                        text: "answer".to_string(),
3506                    }]),
3507                },
3508            ],
3509            tools: None,
3510            max_tokens: 512,
3511            max_tokens_explicit: false,
3512            session_id: None,
3513            cached_content: None,
3514            thinking: None,
3515            tool_choice: None,
3516            response_format: None,
3517            cache: None,
3518        };
3519
3520        let input = build_api_input(&request);
3521        assert_eq!(input.len(), 2);
3522
3523        match &input[0] {
3524            ApiInputItem::Message(ApiMessage {
3525                role: ApiRole::User,
3526                content: ApiMessageContent::Parts(parts),
3527                ..
3528            }) => assert!(matches!(
3529                parts.as_slice(),
3530                [ApiInputContent::InputText { text }] if text == "question"
3531            )),
3532            _ => panic!("expected user message with input_text content"),
3533        }
3534
3535        match &input[1] {
3536            ApiInputItem::Message(ApiMessage {
3537                role: ApiRole::Assistant,
3538                content: ApiMessageContent::Parts(parts),
3539                ..
3540            }) => assert!(matches!(
3541                parts.as_slice(),
3542                [ApiInputContent::OutputText { text }] if text == "answer"
3543            )),
3544            _ => panic!("expected assistant message with output_text content"),
3545        }
3546    }
3547
3548    #[test]
3549    fn test_api_input_content_serialization_uses_current_responses_tags() {
3550        let json = serde_json::to_string(&ApiMessageContent::Parts(vec![
3551            ApiInputContent::InputText {
3552                text: "prompt".to_string(),
3553            },
3554            ApiInputContent::OutputText {
3555                text: "reply".to_string(),
3556            },
3557            ApiInputContent::Image {
3558                image_url: "data:image/png;base64,abc".to_string(),
3559            },
3560            ApiInputContent::File {
3561                filename: "notes.txt".to_string(),
3562                file_data: "data:text/plain;base64,abc".to_string(),
3563            },
3564        ]))
3565        .unwrap();
3566
3567        assert!(json.contains("\"type\":\"input_text\""));
3568        assert!(json.contains("\"type\":\"output_text\""));
3569        assert!(json.contains("\"type\":\"input_image\""));
3570        assert!(json.contains("\"type\":\"input_file\""));
3571    }
3572
3573    #[test]
3574    fn test_build_content_blocks_text() {
3575        let output = vec![ApiOutputItem::Message {
3576            role: "assistant".to_owned(),
3577            phase: Some("final_answer".to_owned()),
3578            content: vec![ApiOutputContent::Text {
3579                text: "Hello!".to_owned(),
3580            }],
3581        }];
3582
3583        let blocks = build_content_blocks(&output);
3584        assert_eq!(blocks.len(), 2);
3585        assert!(matches!(
3586            &blocks[0],
3587            ContentBlock::OpaqueReasoning { data, .. }
3588                if data["phase"] == "final_answer"
3589        ));
3590        assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Hello!"));
3591    }
3592
3593    #[test]
3594    fn assistant_message_phases_round_trip_without_duplicate_text() -> anyhow::Result<()> {
3595        let output: Vec<ApiOutputItem> = serde_json::from_value(serde_json::json!([
3596            {
3597                "type": "message",
3598                "role": "assistant",
3599                "phase": "commentary",
3600                "content": [{"type": "output_text", "text": "Working."}]
3601            },
3602            {
3603                "type": "message",
3604                "role": "assistant",
3605                "phase": "final_answer",
3606                "content": [{"type": "output_text", "text": "Done."}]
3607            }
3608        ]))?;
3609        let blocks = build_content_blocks(&output);
3610        let request =
3611            ChatRequest::new(String::new(), vec![Message::assistant_with_content(blocks)]);
3612        let value = serde_json::to_value(build_api_input(&request))?;
3613        let items = value
3614            .as_array()
3615            .context("Codex input must serialize as an array")?;
3616
3617        assert_eq!(items.len(), 2);
3618        assert_eq!(items[0]["phase"], "commentary");
3619        assert_eq!(items[0]["content"][0]["text"], "Working.");
3620        assert_eq!(items[1]["phase"], "final_answer");
3621        assert_eq!(items[1]["content"][0]["text"], "Done.");
3622        assert_eq!(value.to_string().matches("Working.").count(), 1);
3623        assert_eq!(value.to_string().matches("Done.").count(), 1);
3624
3625        let direct: ApiOutputItem = serde_json::from_value(serde_json::json!({
3626            "type": "message",
3627            "role": "assistant",
3628            "phase": "commentary",
3629            "content": [{"type": "output_text", "text": "Working."}]
3630        }))?;
3631        let direct = output_item_to_input_item(direct)
3632            .context("message output should become a continuation input")?;
3633        assert_eq!(serde_json::to_value(direct)?["phase"], "commentary");
3634        Ok(())
3635    }
3636
3637    #[test]
3638    fn streamed_phase_and_reasoning_summary_preserve_block_order() -> anyhow::Result<()> {
3639        let message: ApiOutputItem = serde_json::from_value(serde_json::json!({
3640            "type": "message",
3641            "role": "assistant",
3642            "phase": "commentary",
3643            "content": [{"type": "output_text", "text": "Working."}]
3644        }))?;
3645        let mut accumulator = crate::streaming::StreamAccumulator::new();
3646        accumulator.apply(&StreamDelta::TextDelta {
3647            delta: "Working.".to_owned(),
3648            block_index: output_block_index(Some(0)),
3649        });
3650        for delta in output_item_stream_deltas(&message, 0, true) {
3651            accumulator.apply(&delta);
3652        }
3653        let message_blocks = accumulator.into_content_blocks();
3654        assert!(matches!(
3655            message_blocks.as_slice(),
3656            [ContentBlock::OpaqueReasoning { data, .. }, ContentBlock::Text { text }]
3657                if data["phase"] == "commentary" && text == "Working."
3658        ));
3659
3660        let reasoning: ApiOutputItem = serde_json::from_value(serde_json::json!({
3661            "type": "reasoning",
3662            "id": "rs_1",
3663            "encrypted_content": "ciphertext",
3664            "summary": [{"type": "summary_text", "text": "Checked."}]
3665        }))?;
3666        let mut accumulator = crate::streaming::StreamAccumulator::new();
3667        for delta in output_item_stream_deltas(&reasoning, 1, true) {
3668            accumulator.apply(&delta);
3669        }
3670        let reasoning_blocks = accumulator.into_content_blocks();
3671        assert!(matches!(
3672            reasoning_blocks.as_slice(),
3673            [ContentBlock::OpaqueReasoning { data, .. }, ContentBlock::Thinking { thinking, .. }]
3674                if data["encrypted_content"] == "ciphertext" && thinking == "Checked."
3675        ));
3676        Ok(())
3677    }
3678
3679    #[test]
3680    fn test_build_content_blocks_function_call() {
3681        let output = vec![ApiOutputItem::FunctionCall {
3682            call_id: "call_123".to_owned(),
3683            name: "test_tool".to_owned(),
3684            arguments: r#"{"key": "value"}"#.to_owned(),
3685        }];
3686
3687        let blocks = build_content_blocks(&output);
3688        assert_eq!(blocks.len(), 1);
3689        assert!(
3690            matches!(&blocks[0], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "test_tool")
3691        );
3692    }
3693
3694    #[test]
3695    fn incomplete_and_refusal_responses_suppress_partial_tools() -> anyhow::Result<()> {
3696        let incomplete: ApiResponse = serde_json::from_value(serde_json::json!({
3697            "id": "resp_incomplete",
3698            "model": "gpt-5.3-codex",
3699            "status": "incomplete",
3700            "incomplete_details": {"reason": "model_context_window_exceeded"},
3701            "output": [{
3702                "type": "function_call",
3703                "call_id": "call_partial",
3704                "name": "lookup",
3705                "arguments": "{"
3706            }]
3707        }))?;
3708        let incomplete = OpenAICodexResponsesProvider::map_response(incomplete);
3709        assert_eq!(
3710            incomplete.stop_reason,
3711            Some(StopReason::ModelContextWindowExceeded)
3712        );
3713        assert!(
3714            !incomplete
3715                .content
3716                .iter()
3717                .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
3718        );
3719
3720        let refusal: ApiResponse = serde_json::from_value(serde_json::json!({
3721            "id": "resp_refusal",
3722            "model": "gpt-5.3-codex",
3723            "status": "completed",
3724            "output": [
3725                {
3726                    "type": "function_call",
3727                    "call_id": "call_partial",
3728                    "name": "lookup",
3729                    "arguments": "{}"
3730                },
3731                {
3732                    "type": "message",
3733                    "role": "assistant",
3734                    "phase": "final_answer",
3735                    "content": [{"type": "refusal", "refusal": "Cannot comply."}]
3736                }
3737            ]
3738        }))?;
3739        let refusal = OpenAICodexResponsesProvider::map_response(refusal);
3740        assert_eq!(refusal.stop_reason, Some(StopReason::Refusal));
3741        assert!(
3742            !refusal
3743                .content
3744                .iter()
3745                .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
3746        );
3747        assert!(matches!(
3748            refusal.content.last(),
3749            Some(ContentBlock::Text { text }) if text == "Cannot comply."
3750        ));
3751        Ok(())
3752    }
3753
3754    #[test]
3755    fn reasoning_output_item_is_preserved_and_summary_is_visible() -> anyhow::Result<()> {
3756        let raw = serde_json::json!({
3757            "type": "reasoning",
3758            "id": "rs_123",
3759            "status": "completed",
3760            "encrypted_content": "ciphertext",
3761            "summary": [
3762                {"type": "summary_text", "text": "Checked the relevant constraints."}
3763            ]
3764        });
3765        let item: ApiOutputItem = serde_json::from_value(raw.clone())?;
3766        let replay_item = output_item_to_input_item(item);
3767        let Some(replay_item) = replay_item else {
3768            anyhow::bail!("reasoning item was not converted to a replay item");
3769        };
3770        assert_eq!(serde_json::to_value(replay_item)?, raw);
3771
3772        let item: ApiOutputItem = serde_json::from_value(raw.clone())?;
3773        let blocks = build_content_blocks(&[item]);
3774        assert_eq!(blocks.len(), 2);
3775        assert!(matches!(
3776            &blocks[0],
3777            ContentBlock::OpaqueReasoning { provider, data, .. }
3778                if provider == OPENAI_RESPONSES_REASONING_PROVIDER && data == &raw
3779        ));
3780        assert!(matches!(
3781            &blocks[1],
3782            ContentBlock::Thinking { thinking, signature, .. }
3783                if thinking == "Checked the relevant constraints." && signature.is_none()
3784        ));
3785        Ok(())
3786    }
3787
3788    #[test]
3789    fn matching_opaque_reasoning_replays_as_a_top_level_item_in_source_order() -> anyhow::Result<()>
3790    {
3791        let raw = serde_json::json!({
3792            "type": "reasoning",
3793            "id": "rs_123",
3794            "encrypted_content": "ciphertext",
3795            "summary": []
3796        });
3797        let request = ChatRequest::new(
3798            "",
3799            vec![agent_sdk_foundation::llm::Message {
3800                role: agent_sdk_foundation::llm::Role::Assistant,
3801                content: Content::Blocks(vec![
3802                    ContentBlock::Text {
3803                        text: "before".to_owned(),
3804                    },
3805                    ContentBlock::OpaqueReasoning {
3806                        provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
3807                        data: raw.clone(),
3808                    },
3809                    ContentBlock::OpaqueReasoning {
3810                        provider: "another-provider".to_owned(),
3811                        data: serde_json::json!({"type": "reasoning", "id": "ignored"}),
3812                    },
3813                    ContentBlock::Text {
3814                        text: "after".to_owned(),
3815                    },
3816                ]),
3817            }],
3818        );
3819
3820        assert_eq!(
3821            serde_json::to_value(build_api_input(&request))?,
3822            serde_json::json!([
3823                {
3824                    "role": "assistant",
3825                    "phase": "final_answer",
3826                    "content": [{"type": "output_text", "text": "before"}]
3827                },
3828                raw,
3829                {
3830                    "role": "assistant",
3831                    "phase": "final_answer",
3832                    "content": [{"type": "output_text", "text": "after"}]
3833                }
3834            ])
3835        );
3836        Ok(())
3837    }
3838
3839    #[test]
3840    fn usage_maps_cache_write_tokens() -> anyhow::Result<()> {
3841        let usage: ApiUsage = serde_json::from_value(serde_json::json!({
3842            "input_tokens": 2048,
3843            "output_tokens": 128,
3844            "input_tokens_details": {
3845                "cached_tokens": 1024,
3846                "cache_write_tokens": 512
3847            }
3848        }))?;
3849
3850        let usage = usage_from_api_usage(&usage);
3851        assert_eq!(usage.cached_input_tokens, 1024);
3852        assert_eq!(usage.cache_creation_input_tokens, 512);
3853        Ok(())
3854    }
3855
3856    #[test]
3857    fn stream_stop_reason_requires_a_semantic_terminal_event() {
3858        let tool_calls = HashMap::new();
3859        assert!(stop_reason_from_stream_state(&tool_calls, None, false, None).is_none());
3860        assert!(matches!(
3861            stop_reason_from_stream_state(&tool_calls, Some(ApiStatus::Completed), false, None,),
3862            Some(StopReason::EndTurn)
3863        ));
3864        assert!(matches!(
3865            stop_reason_from_stream_state(
3866                &tool_calls,
3867                Some(ApiStatus::Incomplete),
3868                false,
3869                Some("max_output_tokens"),
3870            ),
3871            Some(StopReason::MaxTokens)
3872        ));
3873    }
3874
3875    #[test]
3876    fn test_request_serializes_response_format_text_and_forced_tool_choice() {
3877        let request = ApiStreamingRequest {
3878            model: MODEL_GPT53_CODEX.to_string(),
3879            instructions: String::new(),
3880            input: Vec::new(),
3881            tools: None,
3882            max_output_tokens: None,
3883            reasoning: None,
3884            tool_choice: Some(codex_tool_choice(Some(&ToolChoice::Tool(
3885                "respond".to_owned(),
3886            )))),
3887            parallel_tool_calls: None,
3888            store: false,
3889            text: Some(ApiTextSettings {
3890                verbosity: "medium",
3891                format: Some(ApiResponseTextFormat::from(&ResponseFormat::new(
3892                    "person",
3893                    serde_json::json!({"type": "object"}),
3894                ))),
3895            }),
3896            include: None,
3897            prompt_cache_key: None,
3898            stream: true,
3899        };
3900
3901        let json = serde_json::to_value(&request).unwrap();
3902        assert_eq!(json["text"]["format"]["type"], "json_schema");
3903        assert_eq!(json["text"]["format"]["name"], "person");
3904        assert_eq!(json["text"]["format"]["strict"], true);
3905        assert_eq!(json["tool_choice"]["type"], "function");
3906        assert_eq!(json["tool_choice"]["name"], "respond");
3907    }
3908
3909    #[test]
3910    fn test_codex_tool_choice_defaults_to_auto() {
3911        assert_eq!(
3912            serde_json::to_value(codex_tool_choice(None)).unwrap(),
3913            serde_json::json!("auto")
3914        );
3915        assert_eq!(
3916            serde_json::to_value(codex_tool_choice(Some(&ToolChoice::Auto))).unwrap(),
3917            serde_json::json!("auto")
3918        );
3919    }
3920
3921    #[test]
3922    fn test_convert_tool_makes_optional_params_nullable() {
3923        let tool = agent_sdk_foundation::llm::Tool {
3924            name: "t".to_string(),
3925            description: "d".to_string(),
3926            input_schema: serde_json::json!({
3927                "type": "object",
3928                "properties": {
3929                    "req": {"type": "string"},
3930                    "opt": {"type": "string"}
3931                },
3932                "required": ["req"]
3933            }),
3934            display_name: "T".to_string(),
3935            tier: agent_sdk_foundation::ToolTier::Observe,
3936        };
3937
3938        let api_tool = convert_tool(tool);
3939        assert_eq!(api_tool.strict, Some(true));
3940        let schema = api_tool.parameters.unwrap();
3941
3942        let required: Vec<&str> = schema["required"]
3943            .as_array()
3944            .unwrap()
3945            .iter()
3946            .filter_map(|v| v.as_str())
3947            .collect();
3948        assert!(required.contains(&"req"));
3949        assert!(required.contains(&"opt"));
3950
3951        // The previously-optional `opt` must be wrapped in anyOf with a null
3952        // variant so the model is not forced to fabricate a value for it.
3953        let any_of = schema["properties"]["opt"]["anyOf"].as_array().unwrap();
3954        assert!(
3955            any_of
3956                .iter()
3957                .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"))
3958        );
3959    }
3960
3961    #[test]
3962    fn test_convert_tool_disables_strict_for_freeform_object() {
3963        let tool = agent_sdk_foundation::llm::Tool {
3964            name: "t".to_string(),
3965            description: "d".to_string(),
3966            input_schema: serde_json::json!({"type": "object"}),
3967            display_name: "T".to_string(),
3968            tier: agent_sdk_foundation::ToolTier::Observe,
3969        };
3970
3971        let api_tool = convert_tool(tool);
3972        assert_eq!(api_tool.strict, None);
3973    }
3974
3975    #[test]
3976    fn test_emit_accumulated_tool_calls_assigns_distinct_ordered_indices() {
3977        let mut tool_calls = HashMap::new();
3978        tool_calls.insert(
3979            "b".to_string(),
3980            ToolCallAccumulator {
3981                id: "b".to_string(),
3982                name: "second".to_string(),
3983                arguments: "{}".to_string(),
3984                order: 1,
3985                block_index: None,
3986            },
3987        );
3988        tool_calls.insert(
3989            "a".to_string(),
3990            ToolCallAccumulator {
3991                id: "a".to_string(),
3992                name: "first".to_string(),
3993                arguments: "{}".to_string(),
3994                order: 0,
3995                block_index: None,
3996            },
3997        );
3998
3999        let deltas = emit_accumulated_tool_calls(&tool_calls);
4000        let starts: Vec<(String, usize)> = deltas
4001            .iter()
4002            .filter_map(|d| match d {
4003                StreamDelta::ToolUseStart {
4004                    name, block_index, ..
4005                } => Some((name.clone(), *block_index)),
4006                _ => None,
4007            })
4008            .collect();
4009        assert_eq!(
4010            starts,
4011            vec![("first".to_string(), 2), ("second".to_string(), 4)]
4012        );
4013    }
4014
4015    // ────────────────────────────────────────────────────────────────────
4016    // Transport selection: force-HTTP knob + cross-session WS-unhealthy memory
4017    // ────────────────────────────────────────────────────────────────────
4018
4019    use crate::provider::LlmProvider;
4020    use agent_sdk_foundation::llm::{ChatRequest, Message};
4021    use std::sync::atomic::AtomicUsize;
4022    use tokio::io::{AsyncReadExt, AsyncWriteExt};
4023    use tokio::net::TcpListener;
4024
4025    /// A truthy OAuth-shaped token so `build_headers` can extract an account id
4026    /// without a real network call.
4027    fn oauth_token() -> String {
4028        test_token()
4029    }
4030
4031    fn streaming_request(session_id: &str) -> ChatRequest {
4032        ChatRequest::new("You are helpful.", vec![Message::user("hello")])
4033            .with_max_tokens(1024)
4034            .with_session_id(session_id)
4035    }
4036
4037    /// Read a single HTTP/1.1 request head (up to the blank line) from a stream.
4038    async fn read_http_head(stream: &mut tokio::net::TcpStream) -> String {
4039        let mut buf = Vec::new();
4040        let mut byte = [0u8; 1];
4041        while stream.read_exact(&mut byte).await.is_ok() {
4042            buf.push(byte[0]);
4043            if buf.ends_with(b"\r\n\r\n") {
4044                break;
4045            }
4046            if buf.len() > 16 * 1024 {
4047                break;
4048            }
4049        }
4050        String::from_utf8_lossy(&buf).into_owned()
4051    }
4052
4053    /// Minimal SSE body that drives the HTTP fallback to a clean `Done`.
4054    const HTTP_SSE_BODY: &str = concat!(
4055        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n",
4056        "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\"}}\n\n",
4057        "data: [DONE]\n\n",
4058    );
4059
4060    /// Spawn an HTTP-only server that records whether any WebSocket upgrade was
4061    /// attempted and serves a fixed SSE body to plain POSTs. Returns the base
4062    /// URL plus the (`ws_attempts`, `http_requests`) counters.
4063    async fn spawn_http_only_server_with_body(
4064        sse_body: &'static str,
4065    ) -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
4066        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4067        let addr = listener.local_addr().unwrap();
4068        let ws_attempts = Arc::new(AtomicUsize::new(0));
4069        let http_requests = Arc::new(AtomicUsize::new(0));
4070        let ws_attempts_task = ws_attempts.clone();
4071        let http_requests_task = http_requests.clone();
4072
4073        tokio::spawn(async move {
4074            loop {
4075                let Ok((mut stream, _)) = listener.accept().await else {
4076                    break;
4077                };
4078                let ws_attempts = ws_attempts_task.clone();
4079                let http_requests = http_requests_task.clone();
4080                tokio::spawn(async move {
4081                    let head = read_http_head(&mut stream).await;
4082                    if head.to_ascii_lowercase().contains("upgrade: websocket") {
4083                        ws_attempts.fetch_add(1, Ordering::Relaxed);
4084                        // Refuse the upgrade; a black-holed proxy would simply
4085                        // never complete it, but refusing fast keeps the test
4086                        // deterministic.
4087                        let _ = stream
4088                            .write_all(
4089                                b"HTTP/1.1 426 Upgrade Required\r\ncontent-length: 0\r\n\r\n",
4090                            )
4091                            .await;
4092                        return;
4093                    }
4094                    http_requests.fetch_add(1, Ordering::Relaxed);
4095                    let response = format!(
4096                        "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
4097                        sse_body.len(),
4098                        sse_body,
4099                    );
4100                    let _ = stream.write_all(response.as_bytes()).await;
4101                });
4102            }
4103        });
4104
4105        (
4106            format!("http://{addr}/backend-api"),
4107            ws_attempts,
4108            http_requests,
4109        )
4110    }
4111
4112    async fn spawn_http_only_server() -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
4113        spawn_http_only_server_with_body(HTTP_SSE_BODY).await
4114    }
4115
4116    /// Drain a stream, returning whether it completed without a transport error.
4117    async fn drain_ok(provider: &OpenAICodexResponsesProvider, request: ChatRequest) -> bool {
4118        let mut stream = std::pin::pin!(provider.chat_stream(request));
4119        let mut saw_error = false;
4120        while let Some(item) = stream.next().await {
4121            match item {
4122                Ok(StreamDelta::Error { .. }) | Err(_) => saw_error = true,
4123                Ok(_) => {}
4124            }
4125        }
4126        !saw_error
4127    }
4128
4129    #[tokio::test]
4130    async fn websockets_disabled_builder_goes_straight_to_http() {
4131        let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
4132        let provider = OpenAICodexResponsesProvider::with_base_url(
4133            oauth_token(),
4134            MODEL_GPT53_CODEX.to_string(),
4135            base_url,
4136        )
4137        .with_websockets_disabled(true);
4138
4139        assert!(drain_ok(&provider, streaming_request("session-a")).await);
4140
4141        assert_eq!(
4142            ws_attempts.load(Ordering::Relaxed),
4143            0,
4144            "no websocket upgrade may be attempted when websockets are disabled",
4145        );
4146        assert_eq!(http_requests.load(Ordering::Relaxed), 1);
4147    }
4148
4149    #[tokio::test]
4150    async fn premature_http_stream_termination_is_an_error() {
4151        for (case, sse_body) in [
4152            ("done", "data: [DONE]\n\n"),
4153            (
4154                "eof",
4155                "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
4156            ),
4157        ] {
4158            let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
4159            let provider = OpenAICodexResponsesProvider::with_base_url(
4160                oauth_token(),
4161                MODEL_GPT53_CODEX.to_string(),
4162                base_url,
4163            )
4164            .with_websockets_disabled(true);
4165            let mut stream = std::pin::pin!(
4166                provider.chat_stream(streaming_request(&format!("premature-{case}")))
4167            );
4168            let mut saw_error = false;
4169            let mut saw_done = false;
4170            while let Some(item) = stream.next().await {
4171                match item {
4172                    Ok(StreamDelta::Error { .. }) | Err(_) => saw_error = true,
4173                    Ok(StreamDelta::Done { .. }) => saw_done = true,
4174                    Ok(_) => {}
4175                }
4176            }
4177            assert!(saw_error, "case={case} must surface a stream error");
4178            assert!(!saw_done, "case={case} must not synthesize success");
4179        }
4180    }
4181
4182    #[tokio::test]
4183    async fn malformed_http_events_and_items_fail_closed() -> anyhow::Result<()> {
4184        for (case, sse_body) in [
4185            ("event", "data: {not-json}\n\n"),
4186            (
4187                "item",
4188                "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\"}}\n\n",
4189            ),
4190        ] {
4191            let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
4192            let provider = OpenAICodexResponsesProvider::with_base_url(
4193                oauth_token(),
4194                MODEL_GPT53_CODEX.to_owned(),
4195                base_url,
4196            )
4197            .with_websockets_disabled(true);
4198            let mut stream = std::pin::pin!(
4199                provider.chat_stream(streaming_request(&format!("malformed-{case}")))
4200            );
4201            let first = stream
4202                .next()
4203                .await
4204                .context("malformed stream must emit an error")??;
4205            assert!(matches!(
4206                first,
4207                StreamDelta::Error {
4208                    kind: StreamErrorKind::ServerError,
4209                    ..
4210                }
4211            ));
4212            assert!(stream.next().await.is_none());
4213        }
4214        Ok(())
4215    }
4216
4217    #[tokio::test]
4218    async fn non_tool_http_terminals_suppress_partial_tool_calls() -> anyhow::Result<()> {
4219        for (case, sse_body, expected_stop) in [
4220            (
4221                "incomplete",
4222                concat!(
4223                    "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"call_id\":\"call_1\",\"name\":\"lookup\",\"delta\":\"{\"}\n\n",
4224                    "data: {\"type\":\"response.incomplete\",\"response\":{\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
4225                    "data: [DONE]\n\n",
4226                ),
4227                StopReason::MaxTokens,
4228            ),
4229            (
4230                "refusal",
4231                concat!(
4232                    "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"call_id\":\"call_1\",\"name\":\"lookup\",\"delta\":\"{}\"}\n\n",
4233                    "data: {\"type\":\"response.refusal.delta\",\"output_index\":1,\"delta\":\"Cannot comply.\"}\n\n",
4234                    "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n",
4235                    "data: [DONE]\n\n",
4236                ),
4237                StopReason::Refusal,
4238            ),
4239        ] {
4240            let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
4241            let provider = OpenAICodexResponsesProvider::with_base_url(
4242                oauth_token(),
4243                MODEL_GPT53_CODEX.to_owned(),
4244                base_url,
4245            )
4246            .with_websockets_disabled(true);
4247            let mut stream = std::pin::pin!(
4248                provider.chat_stream(streaming_request(&format!("terminal-{case}")))
4249            );
4250            let mut deltas = Vec::new();
4251            while let Some(delta) = stream.next().await {
4252                deltas.push(delta?);
4253            }
4254            assert!(matches!(
4255                deltas.last(),
4256                Some(StreamDelta::Done {
4257                    stop_reason: Some(stop_reason)
4258                }) if *stop_reason == expected_stop
4259            ));
4260            assert!(!deltas.iter().any(|delta| matches!(
4261                delta,
4262                StreamDelta::ToolUseStart { .. } | StreamDelta::ToolInputDelta { .. }
4263            )));
4264        }
4265        Ok(())
4266    }
4267
4268    #[test]
4269    fn parse_disable_websockets_value_recognizes_truthy_values() {
4270        for value in ["1", "true", "TRUE", " yes ", "on"] {
4271            assert!(
4272                parse_disable_websockets_value(Some(value)),
4273                "value={value:?} should disable websockets",
4274            );
4275        }
4276        for value in ["0", "false", "no", "off", "", "maybe"] {
4277            assert!(
4278                !parse_disable_websockets_value(Some(value)),
4279                "value={value:?} should NOT disable websockets",
4280            );
4281        }
4282        assert!(!parse_disable_websockets_value(None));
4283    }
4284
4285    #[tokio::test]
4286    async fn websockets_disabled_via_env_value_goes_straight_to_http() {
4287        // The env var feeds `with_websockets_disabled` through
4288        // `parse_disable_websockets_value` at construction. `std::env::set_var`
4289        // is `unsafe` and rejected by `#![forbid(unsafe_code)]`, so we drive the
4290        // exact value the env reader would produce for `"1"` end-to-end here.
4291        let disabled = parse_disable_websockets_value(Some("1"));
4292        assert!(disabled);
4293
4294        let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
4295        let provider = OpenAICodexResponsesProvider::with_base_url(
4296            oauth_token(),
4297            MODEL_GPT53_CODEX.to_string(),
4298            base_url,
4299        )
4300        .with_websockets_disabled(disabled);
4301
4302        assert!(drain_ok(&provider, streaming_request("session-env")).await);
4303
4304        assert_eq!(
4305            ws_attempts.load(Ordering::Relaxed),
4306            0,
4307            "no websocket upgrade may be attempted when the env var forces HTTP-only",
4308        );
4309        assert_eq!(http_requests.load(Ordering::Relaxed), 1);
4310    }
4311
4312    #[tokio::test]
4313    async fn provider_marked_ws_unhealthy_skips_websocket_on_new_session() {
4314        let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
4315        let provider = OpenAICodexResponsesProvider::with_base_url(
4316            oauth_token(),
4317            MODEL_GPT53_CODEX.to_string(),
4318            base_url,
4319        );
4320
4321        // Simulate a prior session having latched the provider-level transport
4322        // signal after a connectivity failure.
4323        provider.websockets_unhealthy.store(true, Ordering::Relaxed);
4324
4325        // A brand-new session must skip the websocket attempt entirely.
4326        assert!(drain_ok(&provider, streaming_request("fresh-session")).await);
4327
4328        assert_eq!(
4329            ws_attempts.load(Ordering::Relaxed),
4330            0,
4331            "a websocket-unhealthy provider must not attempt a new upgrade",
4332        );
4333        assert_eq!(http_requests.load(Ordering::Relaxed), 1);
4334    }
4335
4336    /// Spawn a server that completes the WebSocket handshake then emits a
4337    /// wrapped 401 error event. Plain POSTs get the HTTP SSE body. Returns the
4338    /// base URL plus the http-request counter (for the fallback assertion).
4339    async fn spawn_ws_unauthorized_server() -> (String, Arc<AtomicUsize>) {
4340        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4341        let addr = listener.local_addr().unwrap();
4342        let http_requests = Arc::new(AtomicUsize::new(0));
4343        let http_requests_task = http_requests.clone();
4344
4345        tokio::spawn(async move {
4346            loop {
4347                let Ok((stream, _)) = listener.accept().await else {
4348                    break;
4349                };
4350                let http_requests = http_requests_task.clone();
4351                tokio::spawn(async move {
4352                    let mut stream = stream;
4353                    // Non-destructively peek the head to route WS vs HTTP, then
4354                    // hand the still-unread stream to the right handler.
4355                    let mut peek = [0u8; 1024];
4356                    let Ok(n) = stream.peek(&mut peek).await else {
4357                        return;
4358                    };
4359                    let head = String::from_utf8_lossy(&peek[..n]).to_ascii_lowercase();
4360
4361                    if head.contains("upgrade: websocket") {
4362                        // `accept_async` reads and completes the handshake from
4363                        // the untouched stream, so no manual SHA-1 is needed.
4364                        let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
4365                            return;
4366                        };
4367                        let payload =
4368                            r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
4369                        let _ = ws
4370                            .send(WebSocketMessage::Text(payload.to_string().into()))
4371                            .await;
4372                        let _ = ws.send(WebSocketMessage::Close(None)).await;
4373                        return;
4374                    }
4375
4376                    // Plain HTTP POST: drain the request head, then reply.
4377                    let _ = read_http_head(&mut stream).await;
4378                    http_requests.fetch_add(1, Ordering::Relaxed);
4379                    let response = format!(
4380                        "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
4381                        HTTP_SSE_BODY.len(),
4382                        HTTP_SSE_BODY,
4383                    );
4384                    let _ = stream.write_all(response.as_bytes()).await;
4385                });
4386            }
4387        });
4388
4389        (format!("http://{addr}/backend-api"), http_requests)
4390    }
4391
4392    #[tokio::test]
4393    async fn ws_unauthorized_disables_session_but_not_provider() {
4394        let (base_url, http_requests) = spawn_ws_unauthorized_server().await;
4395        let provider = OpenAICodexResponsesProvider::with_base_url(
4396            oauth_token(),
4397            MODEL_GPT53_CODEX.to_string(),
4398            base_url,
4399        );
4400
4401        // The websocket warmup hits a wrapped 401, disables the session's
4402        // websocket, and falls back to HTTP — which completes cleanly.
4403        assert!(drain_ok(&provider, streaming_request("auth-session")).await);
4404
4405        // The auth failure is a request problem, NOT a transport problem: the
4406        // provider-level flag must stay clear so a transient blip does not
4407        // force HTTP-only forever.
4408        assert!(
4409            !provider.websockets_unhealthy.load(Ordering::Relaxed),
4410            "a 401 must not mark the provider websocket-transport-unhealthy",
4411        );
4412        // The per-session flag was set, so this same session now goes straight
4413        // to HTTP without another websocket attempt.
4414        let session = provider.websocket_session("auth-session").await;
4415        assert!(session.lock().await.websocket_disabled);
4416
4417        assert!(http_requests.load(Ordering::Relaxed) >= 1);
4418    }
4419}