//! Session- and turn-scoped helpers for talking to model provider APIs. //! //! `ModelClient` is intended to live for the lifetime of a Codex session and holds the stable //! configuration and state needed to talk to a provider (auth, provider selection, conversation id, //! and feature-gated request behavior). //! //! Per-turn settings (model selection, reasoning controls, telemetry context, and turn metadata) //! are passed explicitly to streaming and unary methods so that the turn lifetime is visible at the //! call site. //! //! A [`ModelClientSession`] is created per turn and is used to stream one or more Responses API //! requests during that turn. It caches a Responses WebSocket connection (opened lazily, or reused //! from a session-level preconnect) and stores per-turn state such as the `x-codex-turn-state` //! token used for sticky routing. //! //! Preconnect is intentionally handshake-only: it may warm a socket and capture sticky-routing //! state, but the first `response.create` payload is still sent only when a turn starts. //! //! Internally, startup preconnect stores a single task handle. On first use in a turn, the session //! awaits that task and adopts the warmed socket if it succeeds; if it fails, the stream attempt //! fails and the normal retry/fallback loop decides what to do next. //! //! ## Retry-Budget Tradeoff //! //! Startup preconnect is treated as the first websocket connection attempt for the first turn. If //! it fails, the stream attempt fails and the retry/fallback loop decides whether to retry or fall //! back. This avoids duplicate handshakes but means a failed preconnect can consume one retry //! budget slot before any turn payload is sent. use std::sync::Arc; use std::sync::Mutex; use std::sync::OnceLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use crate::api_bridge::CoreAuthProvider; use crate::api_bridge::auth_provider_from_auth; use crate::api_bridge::map_api_error; use crate::auth::UnauthorizedRecovery; use codex_api::CompactClient as ApiCompactClient; use codex_api::CompactionInput as ApiCompactionInput; use codex_api::MemoriesClient as ApiMemoriesClient; use codex_api::MemoryTrace as ApiMemoryTrace; use codex_api::MemoryTraceSummarizeInput as ApiMemoryTraceSummarizeInput; use codex_api::MemoryTraceSummaryOutput as ApiMemoryTraceSummaryOutput; use codex_api::Prompt as ApiPrompt; use codex_api::RequestTelemetry; use codex_api::ReqwestTransport; use codex_api::ResponseAppendWsRequest; use codex_api::ResponseCreateWsRequest; use codex_api::ResponsesClient as ApiResponsesClient; use codex_api::ResponsesOptions as ApiResponsesOptions; use codex_api::ResponsesWebsocketClient as ApiWebSocketResponsesClient; use codex_api::ResponsesWebsocketConnection as ApiWebSocketConnection; use codex_api::SseTelemetry; use codex_api::TransportError; use codex_api::WebsocketTelemetry; use codex_api::build_conversation_headers; use codex_api::common::Reasoning; use codex_api::common::ResponsesWsRequest; use codex_api::create_text_param_for_request; use codex_api::error::ApiError; use codex_api::requests::responses::Compression; use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::Verbosity as VerbosityConfig; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; use eventsource_stream::Event; use eventsource_stream::EventStreamError; use futures::StreamExt; use futures::future::BoxFuture; use http::HeaderMap as ApiHeaderMap; use http::HeaderValue; use http::StatusCode as HttpStatusCode; use reqwest::StatusCode; use serde_json::Value; use std::time::Duration; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::oneshot::error::TryRecvError; use tokio::task::JoinHandle; use tokio_tungstenite::tungstenite::Error; use tokio_tungstenite::tungstenite::Message; use tracing::warn; use crate::AuthManager; use crate::auth::CodexAuth; use crate::auth::RefreshTokenError; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; use crate::default_client::build_reqwest_client; use crate::error::CodexErr; use crate::error::Result; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; use crate::tools::spec::create_tools_json_for_responses_api; pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; pub const OPENAI_BETA_RESPONSES_WEBSOCKETS: &str = "responses_websockets=2026-02-04"; pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata"; pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str = "x-responsesapi-include-timing-metrics"; const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; struct PreconnectedWebSocket { connection: ApiWebSocketConnection, turn_state: Option, } type PreconnectTask = JoinHandle>; /// Session-scoped state shared by all [`ModelClient`] clones. /// /// This is intentionally kept minimal so `ModelClient` does not need to hold a full `Config`. Most /// configuration is per turn and is passed explicitly to streaming/unary methods. struct ModelClientState { auth_manager: Option>, conversation_id: ThreadId, provider: ModelProviderInfo, session_source: SessionSource, model_verbosity: Option, enable_responses_websockets: bool, enable_responses_websockets_v2: bool, enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, disable_websockets: AtomicBool, preconnect: Mutex>, } impl std::fmt::Debug for ModelClientState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ModelClientState") .field("auth_manager", &self.auth_manager) .field("conversation_id", &self.conversation_id) .field("provider", &self.provider) .field("session_source", &self.session_source) .field("model_verbosity", &self.model_verbosity) .field( "enable_responses_websockets", &self.enable_responses_websockets, ) .field( "enable_request_compression", &self.enable_request_compression, ) .field("include_timing_metrics", &self.include_timing_metrics) .field("beta_features_header", &self.beta_features_header) .field( "disable_websockets", &self.disable_websockets.load(Ordering::Relaxed), ) .field("preconnect", &"") .finish() } } /// Resolved API client setup for a single request attempt. /// /// Keeping this as a single bundle ensures preconnect and normal request paths /// share the same auth/provider setup flow. struct CurrentClientSetup { auth: Option, api_provider: codex_api::Provider, api_auth: CoreAuthProvider, } /// A session-scoped client for model-provider API calls. /// /// This holds configuration and state that should be shared across turns within a Codex session /// (auth, provider selection, conversation id, feature-gated request behavior, and transport /// fallback state). /// /// WebSocket fallback is session-scoped: once a turn activates the HTTP fallback, subsequent turns /// will also use HTTP for the remainder of the session. /// /// Turn-scoped settings (model selection, reasoning controls, telemetry context, and turn /// metadata) are passed explicitly to the relevant methods to keep turn lifetime visible at the /// call site. #[derive(Debug, Clone)] pub struct ModelClient { state: Arc, } /// A turn-scoped streaming session created from a [`ModelClient`]. /// /// The session establishes a Responses WebSocket connection lazily (or adopts a preconnected one) /// and reuses it across multiple requests within the turn. It also caches per-turn state: /// /// - The last request's input items, so subsequent calls can use `response.append` when the input /// is an incremental extension of the previous request. /// - The `x-codex-turn-state` sticky-routing token, which must be replayed for all requests within /// the same turn. /// /// When startup preconnect is still running, first use of this session awaits that in-flight task /// before opening a new websocket so preconnect acts as the first connection attempt for the turn. /// /// Create a fresh `ModelClientSession` for each Codex turn. Reusing it across turns would replay /// the previous turn's sticky-routing token into the next turn, which violates the client/server /// contract and can cause routing bugs. pub struct ModelClientSession { client: ModelClient, connection: Option, websocket_last_items: Vec, websocket_last_response_id: Option, websocket_last_response_id_rx: Option>, /// Turn state for sticky routing. /// /// This is an `OnceLock` that stores the turn state value received from the server /// on turn start via the `x-codex-turn-state` response header. Once set, this value /// should be sent back to the server in the `x-codex-turn-state` request header for /// all subsequent requests within the same turn to maintain sticky routing. /// /// This is a contract between the client and server: we receive it at turn start, /// keep sending it unchanged between turn requests (e.g., for retries, incremental /// appends, or continuation requests), and must not send it between different turns. turn_state: Arc>, } enum WebsocketStreamOutcome { Stream(ResponseStream), FallbackToHttp, } impl ModelClient { #[allow(clippy::too_many_arguments)] /// Creates a new session-scoped `ModelClient`. /// /// All arguments are expected to be stable for the lifetime of a Codex session. Per-turn values /// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly. pub fn new( auth_manager: Option>, conversation_id: ThreadId, provider: ModelProviderInfo, session_source: SessionSource, model_verbosity: Option, enable_responses_websockets: bool, enable_responses_websockets_v2: bool, enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, ) -> Self { Self { state: Arc::new(ModelClientState { auth_manager, conversation_id, provider, session_source, model_verbosity, enable_responses_websockets, enable_responses_websockets_v2, enable_request_compression, include_timing_metrics, beta_features_header, disable_websockets: AtomicBool::new(false), preconnect: Mutex::new(None), }), } } /// Creates a fresh turn-scoped streaming session. /// /// This constructor does not perform network I/O itself. The returned session either adopts a /// previously preconnected websocket or opens a websocket lazily when the first stream request /// is issued. pub fn new_session(&self) -> ModelClientSession { ModelClientSession { client: self.clone(), connection: None, websocket_last_items: Vec::new(), websocket_last_response_id: None, websocket_last_response_id_rx: None, turn_state: Arc::new(OnceLock::new()), } } /// Spawns a best-effort task that warms a websocket for the first turn. /// /// This call performs only connection setup; it never sends prompt payloads. /// /// A timeout when computing turn metadata is treated the same as "no metadata" so startup /// cannot block indefinitely on optional preconnect context. pub fn pre_establish_connection( &self, otel_manager: OtelManager, turn_metadata_header: BoxFuture<'static, Option>, ) { if !self.responses_websocket_enabled() || self.disable_websockets() { return; } let model_client = self.clone(); let handle = tokio::spawn(async move { let turn_metadata_header = turn_metadata_header.await; model_client .preconnect(&otel_manager, turn_metadata_header.as_deref()) .await }); self.set_preconnected_task(Some(handle)); } /// Opportunistically pre-establishes a Responses WebSocket connection for this session. /// /// This method is best-effort: it returns an error on setup/connect failure and the caller /// can decide whether to ignore it. A successful preconnect reduces first-turn latency but /// never sends an initial prompt; the first `response.create` is still sent only when a turn /// starts. /// /// The preconnected slot is single-consumer and single-use: the next `ModelClientSession` may /// adopt it once, after which later turns either keep using that same turn-local connection or /// create a new one. async fn preconnect( &self, otel_manager: &OtelManager, turn_metadata_header: Option<&str>, ) -> Option { if !self.responses_websocket_enabled() || self.disable_websockets() { return None; } let client_setup = self .current_client_setup() .await .map_err(|err| { ApiError::Stream(format!( "failed to build websocket preconnect client setup: {err}" )) }) .ok()?; let turn_state = Arc::new(OnceLock::new()); let connection = self .connect_websocket( otel_manager, client_setup.api_provider, client_setup.api_auth, Some(Arc::clone(&turn_state)), turn_metadata_header, ) .await .ok()?; Some(PreconnectedWebSocket { connection, turn_state: turn_state.get().cloned(), }) } /// Compacts the current conversation history using the Compact endpoint. /// /// This is a unary call (no streaming) that returns a new list of /// `ResponseItem`s representing the compacted transcript. /// /// The model selection and telemetry context are passed explicitly to keep `ModelClient` /// session-scoped. pub async fn compact_conversation_history( &self, prompt: &Prompt, model_info: &ModelInfo, otel_manager: &OtelManager, ) -> Result> { if prompt.input.is_empty() { return Ok(Vec::new()); } let client_setup = self.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); let request_telemetry = Self::build_request_telemetry(otel_manager); let client = ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth) .with_telemetry(Some(request_telemetry)); let instructions = prompt.base_instructions.text.clone(); let payload = ApiCompactionInput { model: &model_info.slug, input: &prompt.input, instructions: &instructions, }; let extra_headers = self.build_subagent_headers(); client .compact_input(&payload, extra_headers) .await .map_err(map_api_error) } /// Builds memory summaries for each provided normalized trace. /// /// This is a unary call (no streaming) to `/v1/memories/trace_summarize`. /// /// The model selection, reasoning effort, and telemetry context are passed explicitly to keep /// `ModelClient` session-scoped. pub async fn summarize_memory_traces( &self, traces: Vec, model_info: &ModelInfo, effort: Option, otel_manager: &OtelManager, ) -> Result> { if traces.is_empty() { return Ok(Vec::new()); } let client_setup = self.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); let request_telemetry = Self::build_request_telemetry(otel_manager); let client = ApiMemoriesClient::new(transport, client_setup.api_provider, client_setup.api_auth) .with_telemetry(Some(request_telemetry)); let payload = ApiMemoryTraceSummarizeInput { model: model_info.slug.clone(), traces, reasoning: effort.map(|effort| Reasoning { effort: Some(effort), summary: None, }), }; client .trace_summarize_input(&payload, self.build_subagent_headers()) .await .map_err(map_api_error) } fn build_subagent_headers(&self) -> ApiHeaderMap { let mut extra_headers = ApiHeaderMap::new(); if let SessionSource::SubAgent(sub) = &self.state.session_source { let subagent = match sub { crate::protocol::SubAgentSource::Review => "review".to_string(), crate::protocol::SubAgentSource::Compact => "compact".to_string(), crate::protocol::SubAgentSource::ThreadSpawn { .. } => "collab_spawn".to_string(), crate::protocol::SubAgentSource::Other(label) => label.clone(), }; if let Ok(val) = HeaderValue::from_str(&subagent) { extra_headers.insert("x-openai-subagent", val); } } extra_headers } /// Builds request telemetry for unary API calls (e.g., Compact endpoint). fn build_request_telemetry(otel_manager: &OtelManager) -> Arc { let telemetry = Arc::new(ApiTelemetry::new(otel_manager.clone())); let request_telemetry: Arc = telemetry; request_telemetry } /// Returns whether this session is configured to use Responses-over-WebSocket. /// /// This combines provider capability and feature gating; both must be true for websocket paths /// to be eligible. fn responses_websocket_enabled(&self) -> bool { self.state.provider.supports_websockets && self.state.enable_responses_websockets } fn responses_websockets_v2_enabled(&self) -> bool { self.state.enable_responses_websockets_v2 } /// Returns whether websocket transport has been permanently disabled for this session. /// /// Once set by fallback activation, subsequent turns must stay on HTTP transport. fn disable_websockets(&self) -> bool { self.state.disable_websockets.load(Ordering::Relaxed) } /// Returns auth + provider configuration resolved from the current session auth state. /// /// This centralizes setup used by both preconnect and normal request paths so they stay in /// lockstep when auth/provider resolution changes. async fn current_client_setup(&self) -> Result { let auth = match self.state.auth_manager.as_ref() { Some(manager) => manager.auth().await, None => None, }; let api_provider = self .state .provider .to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?; let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?; Ok(CurrentClientSetup { auth, api_provider, api_auth, }) } /// Opens a websocket connection using the same header and telemetry wiring as normal turns. /// /// Both startup preconnect and in-turn `needs_new` reconnects call this path so handshake /// behavior remains consistent across both flows. async fn connect_websocket( &self, otel_manager: &OtelManager, api_provider: codex_api::Provider, api_auth: CoreAuthProvider, turn_state: Option>>, turn_metadata_header: Option<&str>, ) -> std::result::Result { let headers = self.build_websocket_headers(turn_state.as_ref(), turn_metadata_header); let websocket_telemetry = ModelClientSession::build_websocket_telemetry(otel_manager); ApiWebSocketResponsesClient::new(api_provider, api_auth) .connect(headers, turn_state, Some(websocket_telemetry)) .await } /// Builds websocket handshake headers for both preconnect and turn-time reconnect. /// /// Callers should pass the current turn-state lock when available so sticky-routing state is /// replayed on reconnect within the same turn. fn build_websocket_headers( &self, turn_state: Option<&Arc>>, turn_metadata_header: Option<&str>, ) -> ApiHeaderMap { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); let mut headers = build_responses_headers( self.state.beta_features_header.as_deref(), turn_state, turn_metadata_header.as_ref(), ); headers.extend(build_conversation_headers(Some( self.state.conversation_id.to_string(), ))); let responses_websockets_beta_header = if self.responses_websockets_v2_enabled() { RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE } else { OPENAI_BETA_RESPONSES_WEBSOCKETS }; headers.insert( OPENAI_BETA_HEADER, HeaderValue::from_static(responses_websockets_beta_header), ); if self.state.include_timing_metrics { headers.insert( X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER, HeaderValue::from_static("true"), ); } headers } /// Consumes the warmed websocket task slot. fn take_preconnected_task(&self) -> Option { let mut state = self .state .preconnect .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); state.take() } fn set_preconnected_task(&self, task: Option) { let mut state = self .state .preconnect .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(running_task) = state.take() { running_task.abort(); } *state = task; } } impl ModelClientSession { fn activate_http_fallback(&self, websocket_enabled: bool) -> bool { websocket_enabled && !self .client .state .disable_websockets .swap(true, Ordering::Relaxed) } fn build_responses_request(prompt: &Prompt) -> Result { let instructions = prompt.base_instructions.text.clone(); let tools_json: Vec = create_tools_json_for_responses_api(&prompt.tools)?; Ok(build_api_prompt(prompt, instructions, tools_json)) } #[allow(clippy::too_many_arguments)] /// Builds shared Responses API request options for both HTTP and WebSocket streaming. /// /// Keeping option construction in one place ensures request-scoped headers are consistent /// regardless of transport choice. fn build_responses_options( &self, prompt: &Prompt, model_info: &ModelInfo, effort: Option, summary: ReasoningSummaryConfig, turn_metadata_header: Option<&str>, compression: Compression, ) -> ApiResponsesOptions { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); let default_reasoning_effort = model_info.default_reasoning_level; let reasoning = if model_info.supports_reasoning_summaries { Some(Reasoning { effort: effort.or(default_reasoning_effort), summary: if summary == ReasoningSummaryConfig::None { None } else { Some(summary) }, }) } else { None }; let include = if reasoning.is_some() { vec!["reasoning.encrypted_content".to_string()] } else { Vec::new() }; let verbosity = if model_info.support_verbosity { self.client .state .model_verbosity .or(model_info.default_verbosity) } else { if self.client.state.model_verbosity.is_some() { warn!( "model_verbosity is set but ignored as the model does not support verbosity: {}", model_info.slug ); } None }; let text = create_text_param_for_request(verbosity, &prompt.output_schema); let conversation_id = self.client.state.conversation_id.to_string(); ApiResponsesOptions { reasoning, include, prompt_cache_key: Some(conversation_id.clone()), text, store_override: None, conversation_id: Some(conversation_id), session_source: Some(self.client.state.session_source.clone()), extra_headers: build_responses_headers( self.client.state.beta_features_header.as_deref(), Some(&self.turn_state), turn_metadata_header.as_ref(), ), compression, turn_state: Some(Arc::clone(&self.turn_state)), } } fn get_incremental_items(&self, input_items: &[ResponseItem]) -> Option> { // Checks whether the current request input is an incremental append to the previous request. // If items in the new request contain all the items from the previous request we build // a response.append request otherwise we start with a fresh response.create request. let previous_len = self.websocket_last_items.len(); let can_append = previous_len > 0 && input_items.starts_with(&self.websocket_last_items) && previous_len < input_items.len(); if can_append { Some(input_items[previous_len..].to_vec()) } else { None } } fn refresh_websocket_last_response_id(&mut self) { if let Some(mut receiver) = self.websocket_last_response_id_rx.take() { match receiver.try_recv() { Ok(response_id) if !response_id.is_empty() => { self.websocket_last_response_id = Some(response_id); } Ok(_) | Err(TryRecvError::Closed) => { self.websocket_last_response_id = None; } Err(TryRecvError::Empty) => { self.websocket_last_response_id_rx = Some(receiver); } } } } fn websocket_previous_response_id(&mut self) -> Option { self.refresh_websocket_last_response_id(); self.websocket_last_response_id .clone() .filter(|id| !id.is_empty()) } fn prepare_websocket_create_request( &self, model_slug: &str, api_prompt: &ApiPrompt, options: &ApiResponsesOptions, input: Vec, previous_response_id: Option, ) -> ResponsesWsRequest { let ApiResponsesOptions { reasoning, include, prompt_cache_key, text, store_override, .. } = options; let store = store_override.unwrap_or(false); let payload = ResponseCreateWsRequest { model: model_slug.to_string(), instructions: api_prompt.instructions.clone(), previous_response_id, input, tools: api_prompt.tools.clone(), tool_choice: "auto".to_string(), parallel_tool_calls: api_prompt.parallel_tool_calls, reasoning: reasoning.clone(), store, stream: true, include: include.clone(), prompt_cache_key: prompt_cache_key.clone(), text: text.clone(), }; ResponsesWsRequest::ResponseCreate(payload) } fn prepare_websocket_request( &mut self, model_slug: &str, api_prompt: &ApiPrompt, options: &ApiResponsesOptions, ) -> ResponsesWsRequest { let responses_websockets_v2_enabled = self.client.responses_websockets_v2_enabled(); let incremental_items = self.get_incremental_items(&api_prompt.input); if let Some(append_items) = incremental_items { if responses_websockets_v2_enabled && let Some(previous_response_id) = self.websocket_previous_response_id() { return self.prepare_websocket_create_request( model_slug, api_prompt, options, append_items, Some(previous_response_id), ); } if !responses_websockets_v2_enabled { return ResponsesWsRequest::ResponseAppend(ResponseAppendWsRequest { input: append_items, }); } } self.prepare_websocket_create_request( model_slug, api_prompt, options, api_prompt.input.clone(), None, ) } /// Returns a websocket connection for this turn, reusing preconnect when possible. /// /// This method first tries to adopt the session-level preconnect slot, then falls back to a /// fresh websocket handshake only when the turn has no live connection. If startup preconnect /// is still running, it is awaited first so that task acts as the first connection attempt for /// this turn instead of racing a second handshake. If that attempt fails, the normal connect /// and stream retry flow continues unchanged. async fn websocket_connection( &mut self, otel_manager: &OtelManager, api_provider: codex_api::Provider, api_auth: CoreAuthProvider, turn_metadata_header: Option<&str>, options: &ApiResponsesOptions, ) -> std::result::Result<&ApiWebSocketConnection, ApiError> { // Prefer the session-level preconnect slot before creating a new websocket. if self.connection.is_none() && let Some(task) = self.client.take_preconnected_task() { match task.await { Ok(Some(preconnected)) => { let PreconnectedWebSocket { connection, turn_state, } = preconnected; if let Some(turn_state) = turn_state { let _ = self.turn_state.set(turn_state); } self.connection = Some(connection); } _ => { warn!("startup websocket preconnect task failed"); } }; } let needs_new = match self.connection.as_ref() { Some(conn) => conn.is_closed().await, None => true, }; if needs_new { self.websocket_last_items.clear(); self.websocket_last_response_id = None; self.websocket_last_response_id_rx = None; let turn_state = options .turn_state .clone() .unwrap_or_else(|| Arc::clone(&self.turn_state)); let new_conn = self .client .connect_websocket( otel_manager, api_provider, api_auth, Some(turn_state), turn_metadata_header, ) .await?; self.connection = Some(new_conn); } self.connection.as_ref().ok_or(ApiError::Stream( "websocket connection is unavailable".to_string(), )) } fn responses_request_compression(&self, auth: Option<&crate::auth::CodexAuth>) -> Compression { if self.client.state.enable_request_compression && auth.is_some_and(CodexAuth::is_chatgpt_auth) && self.client.state.provider.is_openai() { Compression::Zstd } else { Compression::None } } /// Streams a turn via the OpenAI Responses API. /// /// Handles SSE fixtures, reasoning summaries, verbosity, and the /// `text` controls used for output schemas. #[allow(clippy::too_many_arguments)] async fn stream_responses_api( &self, prompt: &Prompt, model_info: &ModelInfo, otel_manager: &OtelManager, effort: Option, summary: ReasoningSummaryConfig, turn_metadata_header: Option<&str>, ) -> Result { if let Some(path) = &*CODEX_RS_SSE_FIXTURE { warn!(path, "Streaming from fixture"); let stream = codex_api::stream_from_fixture( path, self.client.state.provider.stream_idle_timeout(), ) .map_err(map_api_error)?; return Ok(map_response_stream(stream, otel_manager.clone())); } let auth_manager = self.client.state.auth_manager.clone(); let api_prompt = Self::build_responses_request(prompt)?; let mut auth_recovery = auth_manager .as_ref() .map(super::auth::AuthManager::unauthorized_recovery); loop { let client_setup = self.client.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); let (request_telemetry, sse_telemetry) = Self::build_streaming_telemetry(otel_manager); let compression = self.responses_request_compression(client_setup.auth.as_ref()); let client = ApiResponsesClient::new( transport, client_setup.api_provider, client_setup.api_auth, ) .with_telemetry(Some(request_telemetry), Some(sse_telemetry)); let options = self.build_responses_options( prompt, model_info, effort, summary, turn_metadata_header, compression, ); let stream_result = client .stream_prompt(&model_info.slug, &api_prompt, options) .await; match stream_result { Ok(stream) => { return Ok(map_response_stream(stream, otel_manager.clone())); } Err(ApiError::Transport( unauthorized_transport @ TransportError::Http { status, .. }, )) if status == StatusCode::UNAUTHORIZED => { handle_unauthorized(unauthorized_transport, &mut auth_recovery).await?; continue; } Err(err) => return Err(map_api_error(err)), } } } /// Streams a turn via the Responses API over WebSocket transport. #[allow(clippy::too_many_arguments)] async fn stream_responses_websocket( &mut self, prompt: &Prompt, model_info: &ModelInfo, otel_manager: &OtelManager, effort: Option, summary: ReasoningSummaryConfig, turn_metadata_header: Option<&str>, ) -> Result { let auth_manager = self.client.state.auth_manager.clone(); let api_prompt = Self::build_responses_request(prompt)?; let mut auth_recovery = auth_manager .as_ref() .map(super::auth::AuthManager::unauthorized_recovery); loop { let client_setup = self.client.current_client_setup().await?; let compression = self.responses_request_compression(client_setup.auth.as_ref()); let options = self.build_responses_options( prompt, model_info, effort, summary, turn_metadata_header, compression, ); match self .websocket_connection( otel_manager, client_setup.api_provider, client_setup.api_auth, turn_metadata_header, &options, ) .await { Ok(_) => {} Err(ApiError::Transport(TransportError::Http { status, .. })) if status == StatusCode::UPGRADE_REQUIRED => { return Ok(WebsocketStreamOutcome::FallbackToHttp); } Err(ApiError::Transport( unauthorized_transport @ TransportError::Http { status, .. }, )) if status == StatusCode::UNAUTHORIZED => { handle_unauthorized(unauthorized_transport, &mut auth_recovery).await?; continue; } Err(err) => return Err(map_api_error(err)), } let request = self.prepare_websocket_request(&model_info.slug, &api_prompt, &options); let stream_result = self .connection .as_ref() .ok_or_else(|| { map_api_error(ApiError::Stream( "websocket connection is unavailable".to_string(), )) })? .stream_request(request) .await .map_err(map_api_error)?; self.websocket_last_items = api_prompt.input.clone(); let (last_response_id_sender, last_response_id_receiver) = oneshot::channel(); self.websocket_last_response_id_rx = Some(last_response_id_receiver); let mut last_response_id_sender = Some(last_response_id_sender); let stream_result = stream_result.inspect(move |event| { if let Ok(ResponseEvent::Completed { response_id, .. }) = event && !response_id.is_empty() && let Some(sender) = last_response_id_sender.take() { let _ = sender.send(response_id.clone()); } }); return Ok(WebsocketStreamOutcome::Stream(map_response_stream( stream_result, otel_manager.clone(), ))); } } /// Builds request and SSE telemetry for streaming API calls. fn build_streaming_telemetry( otel_manager: &OtelManager, ) -> (Arc, Arc) { let telemetry = Arc::new(ApiTelemetry::new(otel_manager.clone())); let request_telemetry: Arc = telemetry.clone(); let sse_telemetry: Arc = telemetry; (request_telemetry, sse_telemetry) } /// Builds telemetry for the Responses API WebSocket transport. fn build_websocket_telemetry(otel_manager: &OtelManager) -> Arc { let telemetry = Arc::new(ApiTelemetry::new(otel_manager.clone())); let websocket_telemetry: Arc = telemetry; websocket_telemetry } #[allow(clippy::too_many_arguments)] /// Streams a single model request within the current turn. /// /// The caller is responsible for passing per-turn settings explicitly (model selection, /// reasoning settings, telemetry context, and turn metadata). This method will prefer the /// Responses WebSocket transport when enabled and healthy, and will fall back to the HTTP /// Responses API transport otherwise. pub async fn stream( &mut self, prompt: &Prompt, model_info: &ModelInfo, otel_manager: &OtelManager, effort: Option, summary: ReasoningSummaryConfig, turn_metadata_header: Option<&str>, ) -> Result { let wire_api = self.client.state.provider.wire_api; match wire_api { WireApi::Responses => { let websocket_enabled = self.client.responses_websocket_enabled() && !self.client.disable_websockets(); if websocket_enabled { match self .stream_responses_websocket( prompt, model_info, otel_manager, effort, summary, turn_metadata_header, ) .await? { WebsocketStreamOutcome::Stream(stream) => return Ok(stream), WebsocketStreamOutcome::FallbackToHttp => { self.try_switch_fallback_transport(otel_manager); } } } self.stream_responses_api( prompt, model_info, otel_manager, effort, summary, turn_metadata_header, ) .await } } } /// Permanently disables WebSockets for this Codex session and resets WebSocket state. /// /// This is used after exhausting the provider retry budget, to force subsequent requests onto /// the HTTP transport. It also clears any warmed websocket preconnect state so future turns /// cannot accidentally adopt a stale socket after fallback has been activated. /// /// Returns `true` if this call activated fallback, or `false` if fallback was already active. pub(crate) fn try_switch_fallback_transport(&mut self, otel_manager: &OtelManager) -> bool { let websocket_enabled = self.client.responses_websocket_enabled(); let activated = self.activate_http_fallback(websocket_enabled); if activated { warn!("falling back to HTTP"); otel_manager.counter( "codex.transport.fallback_to_http", 1, &[("from_wire_api", "responses_websocket")], ); self.client.set_preconnected_task(None); self.connection = None; self.websocket_last_items.clear(); } activated } } /// Adapts the core `Prompt` type into the `codex-api` payload shape. fn build_api_prompt(prompt: &Prompt, instructions: String, tools_json: Vec) -> ApiPrompt { ApiPrompt { instructions, input: prompt.get_formatted_input(), tools: tools_json, parallel_tool_calls: prompt.parallel_tool_calls, output_schema: prompt.output_schema.clone(), } } /// Parses per-turn metadata into an HTTP header value. /// /// Invalid values are treated as absent so callers can compare and propagate /// metadata with the same sanitization path used when constructing headers. fn parse_turn_metadata_header(turn_metadata_header: Option<&str>) -> Option { turn_metadata_header.and_then(|value| HeaderValue::from_str(value).ok()) } /// Builds the extra headers attached to Responses API requests. /// /// These headers implement Codex-specific conventions: /// /// - `x-codex-beta-features`: comma-separated beta feature keys enabled for the session. /// - `x-codex-turn-state`: sticky routing token captured earlier in the turn. /// - `x-codex-turn-metadata`: optional per-turn metadata for observability. fn build_responses_headers( beta_features_header: Option<&str>, turn_state: Option<&Arc>>, turn_metadata_header: Option<&HeaderValue>, ) -> ApiHeaderMap { let mut headers = ApiHeaderMap::new(); if let Some(value) = beta_features_header && !value.is_empty() && let Ok(header_value) = HeaderValue::from_str(value) { headers.insert("x-codex-beta-features", header_value); } if let Some(turn_state) = turn_state && let Some(state) = turn_state.get() && let Ok(header_value) = HeaderValue::from_str(state) { headers.insert(X_CODEX_TURN_STATE_HEADER, header_value); } if let Some(header_value) = turn_metadata_header { headers.insert(X_CODEX_TURN_METADATA_HEADER, header_value.clone()); } headers } fn map_response_stream(api_stream: S, otel_manager: OtelManager) -> ResponseStream where S: futures::Stream> + Unpin + Send + 'static, { let (tx_event, rx_event) = mpsc::channel::>(1600); tokio::spawn(async move { let mut logged_error = false; let mut api_stream = api_stream; while let Some(event) = api_stream.next().await { match event { Ok(ResponseEvent::Completed { response_id, token_usage, }) => { if let Some(usage) = &token_usage { otel_manager.sse_event_completed( usage.input_tokens, usage.output_tokens, Some(usage.cached_input_tokens), Some(usage.reasoning_output_tokens), usage.total_tokens, ); } if tx_event .send(Ok(ResponseEvent::Completed { response_id, token_usage, })) .await .is_err() { return; } } Ok(event) => { if tx_event.send(Ok(event)).await.is_err() { return; } } Err(err) => { let mapped = map_api_error(err); if !logged_error { otel_manager.see_event_completed_failed(&mapped); logged_error = true; } if tx_event.send(Err(mapped)).await.is_err() { return; } } } } }); ResponseStream { rx_event } } /// Handles a 401 response by optionally refreshing ChatGPT tokens once. /// /// When refresh succeeds, the caller should retry the API call; otherwise /// the mapped `CodexErr` is returned to the caller. async fn handle_unauthorized( transport: TransportError, auth_recovery: &mut Option, ) -> Result<()> { if let Some(recovery) = auth_recovery && recovery.has_next() { return match recovery.next().await { Ok(_) => Ok(()), Err(RefreshTokenError::Permanent(failed)) => Err(CodexErr::RefreshTokenFailed(failed)), Err(RefreshTokenError::Transient(other)) => Err(CodexErr::Io(other)), }; } Err(map_api_error(ApiError::Transport(transport))) } struct ApiTelemetry { otel_manager: OtelManager, } impl ApiTelemetry { fn new(otel_manager: OtelManager) -> Self { Self { otel_manager } } } impl RequestTelemetry for ApiTelemetry { fn on_request( &self, attempt: u64, status: Option, error: Option<&TransportError>, duration: Duration, ) { let error_message = error.map(std::string::ToString::to_string); self.otel_manager.record_api_request( attempt, status.map(|s| s.as_u16()), error_message.as_deref(), duration, ); } } impl SseTelemetry for ApiTelemetry { fn on_sse_poll( &self, result: &std::result::Result< Option>>, tokio::time::error::Elapsed, >, duration: Duration, ) { self.otel_manager.log_sse_event(result, duration); } } impl WebsocketTelemetry for ApiTelemetry { fn on_ws_request(&self, duration: Duration, error: Option<&ApiError>) { let error_message = error.map(std::string::ToString::to_string); self.otel_manager .record_websocket_request(duration, error_message.as_deref()); } fn on_ws_event( &self, result: &std::result::Result>, ApiError>, duration: Duration, ) { self.otel_manager.record_websocket_event(result, duration); } }