From 8d95057c1fcd05a11b18d3db0522d020c5470124 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 8 Jul 2026 12:58:51 -0700 Subject: [PATCH 1/5] core: preserve Responses WebSockets with system proxy --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 + codex-rs/cli/src/doctor.rs | 2 + codex-rs/codex-api/Cargo.toml | 1 + .../src/endpoint/responses_websocket.rs | 42 ++++++-------- codex-rs/core/src/client.rs | 1 + .../core/tests/suite/client_websockets.rs | 56 ++++++++++++++++++- 7 files changed, 77 insertions(+), 27 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9856ef9cd3..3d98479749 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1940,6 +1940,7 @@ dependencies = [ "codex-http-client", "codex-protocol", "codex-utils-rustls-provider", + "codex-websocket-client", "eventsource-stream", "futures", "http 1.4.0", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0085b663ef..7b153dfaa0 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -168,6 +168,7 @@ codex-code-mode = { path = "code-mode" } codex-code-mode-protocol = { path = "code-mode-protocol" } codex-home = { path = "codex-home" } codex-http-client = { path = "http-client" } +codex-websocket-client = { path = "websocket-client" } codex-config = { path = "config" } codex-connectors = { path = "connectors" } codex-connectors-extension = { path = "ext/connectors" } diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 2e9c0094c4..7f06a611bc 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -2383,9 +2383,11 @@ async fn websocket_reachability_check( HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE), ); let client = ResponsesWebsocketClient::new(api_provider, api_auth); + let http_client_factory = config.http_client_factory(); match tokio::time::timeout( provider.websocket_connect_timeout(), client.probe_handshake( + &http_client_factory, extra_headers, default_headers(), WEBSOCKET_IMMEDIATE_CLOSE_GRACE, diff --git a/codex-rs/codex-api/Cargo.toml b/codex-rs/codex-api/Cargo.toml index 084b058e12..21f6b67425 100644 --- a/codex-rs/codex-api/Cargo.toml +++ b/codex-rs/codex-api/Cargo.toml @@ -13,6 +13,7 @@ codex-client = { workspace = true } codex-http-client = { workspace = true } codex-protocol = { workspace = true } codex-utils-rustls-provider = { workspace = true } +codex-websocket-client = { workspace = true } futures = { workspace = true } http = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index cce056f638..88e7fbb9b4 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -11,8 +11,9 @@ use crate::sse::ResponsesStreamEvent; use crate::sse::process_responses_event; use crate::telemetry::WebsocketTelemetry; use codex_client::TransportError; -use codex_http_client::maybe_build_rustls_client_config_with_custom_ca; -use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use codex_http_client::HttpClientFactory; +use codex_websocket_client::WebSocketConnection; +use codex_websocket_client::WebSocketConnector; use futures::SinkExt; use futures::StreamExt; use http::HeaderMap; @@ -25,14 +26,10 @@ use serde_json::map::Map as JsonMap; use std::sync::Arc; use std::sync::OnceLock; use std::time::Duration; -use tokio::net::TcpStream; use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::time::Instant; -use tokio_tungstenite::MaybeTlsStream; -use tokio_tungstenite::WebSocketStream; -use tokio_tungstenite::connect_async_tls_with_config; use tokio_tungstenite::tungstenite::Error as WsError; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -62,7 +59,7 @@ enum WsCommand { } impl WsStream { - fn new(inner: WebSocketStream>) -> Self { + fn new(inner: WebSocketConnection) -> Self { let (tx_command, mut rx_command) = mpsc::channel::(32); let (tx_message, rx_message) = mpsc::unbounded_channel::>(); @@ -334,6 +331,7 @@ impl ResponsesWebsocketClient { )] pub async fn connect( &self, + http_client_factory: &HttpClientFactory, extra_headers: HeaderMap, default_headers: HeaderMap, turn_state: Option>>, @@ -349,7 +347,7 @@ impl ResponsesWebsocketClient { self.auth.add_auth_headers(&mut headers); let (stream, _status, server_reasoning_included, models_etag, server_model) = - connect_websocket(ws_url, headers, turn_state.clone()).await?; + connect_websocket(ws_url, headers, http_client_factory, turn_state.clone()).await?; Ok(ResponsesWebsocketConnection::new( stream, self.provider.stream_idle_timeout, @@ -369,6 +367,7 @@ impl ResponsesWebsocketClient { /// a usable connection from a policy rejection that closes right away. pub async fn probe_handshake( &self, + http_client_factory: &HttpClientFactory, extra_headers: HeaderMap, default_headers: HeaderMap, immediate_close_timeout: Duration, @@ -383,7 +382,13 @@ impl ResponsesWebsocketClient { self.auth.add_auth_headers(&mut headers); let (mut stream, status, reasoning_included, models_etag, server_model) = - connect_websocket(ws_url.clone(), headers, /*turn_state*/ None).await?; + connect_websocket( + ws_url.clone(), + headers, + http_client_factory, + /*turn_state*/ None, + ) + .await?; let immediate_close = tokio::time::timeout(immediate_close_timeout, stream.next()) .await .ok() @@ -437,9 +442,9 @@ fn merge_request_headers( async fn connect_websocket( url: Url, headers: HeaderMap, + http_client_factory: &HttpClientFactory, turn_state: Option>>, ) -> Result<(WsStream, StatusCode, bool, Option, Option), ApiError> { - ensure_rustls_crypto_provider(); info!("connecting to websocket: {url}"); let mut request = url @@ -448,20 +453,9 @@ async fn connect_websocket( .map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?; request.headers_mut().extend(headers); - // Secure websocket traffic needs the same custom-CA policy as reqwest-based HTTPS traffic. - // If a Codex-specific CA bundle is configured, build an explicit rustls connector so this - // websocket path does not fall back to tungstenite's default native-roots-only behavior. - let connector = maybe_build_rustls_client_config_with_custom_ca() - .map_err(|err| ApiError::Stream(format!("failed to configure websocket TLS: {err}")))? - .map(tokio_tungstenite::Connector::Rustls); - - let response = connect_async_tls_with_config( - request, - Some(websocket_config()), - false, // `false` means "do not disable Nagle", which is tungstenite's recommended default. - connector, - ) - .await; + let connector = WebSocketConnector::new(http_client_factory) + .map_err(|err| ApiError::Stream(format!("failed to configure websocket TLS: {err}")))?; + let response = connector.connect(request, websocket_config()).await; let (stream, response) = match response { Ok((stream, response)) => { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 8c98a3372e..2bd4ae3bcf 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1006,6 +1006,7 @@ impl ModelClient { let result = match tokio::time::timeout( websocket_connect_timeout, ApiWebSocketResponsesClient::new(api_provider, api_auth).connect( + &self.http_client_factory, headers, codex_login::default_client::default_headers(), /*turn_state*/ None, diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 05a93807b2..71d0965ef9 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -8,6 +8,7 @@ use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; use codex_features::Feature; +use codex_http_client::OutboundProxyPolicy; use codex_login::CodexAuth; use codex_login::auth::AgentIdentityAuthPolicy; use codex_model_provider_info::ModelProviderInfo; @@ -102,6 +103,7 @@ fn assert_request_trace_matches(body: &serde_json::Value, expected_trace: &W3cTr struct WebsocketTestHarness { _codex_home: TempDir, client: ModelClient, + outbound_proxy_policy: OutboundProxyPolicy, session_id: SessionId, thread_id: ThreadId, model_info: ModelInfo, @@ -230,6 +232,38 @@ async fn responses_websocket_streams_without_feature_flag_when_provider_supports server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_streams_with_system_proxy_feature() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![vec![ + ev_response_created("resp-1"), + ev_completed("resp-1"), + ]]]) + .await; + + let harness = websocket_harness_with_provider_options( + websocket_provider(&server), + /*runtime_metrics_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, + /*enabled_features*/ &[Feature::RespectSystemProxy], + ) + .await; + assert_eq!( + harness.outbound_proxy_policy, + OutboundProxyPolicy::RespectSystemProxy + ); + let mut client_session = harness.client.new_session(); + let prompt = prompt_with_input(vec![message_item("hello")]); + + stream_until_complete(&mut client_session, &harness, &prompt).await; + + assert_eq!(server.handshakes().len(), 1); + assert_eq!(server.single_connection().len(), 1); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_reuses_connection_with_per_turn_trace_payloads() { skip_if_no_network!(); @@ -389,8 +423,10 @@ async fn responses_websocket_request_prewarm_reuses_connection() { let mut provider = websocket_provider(&server); provider.name = ModelProviderInfo::create_openai_provider(/*base_url*/ None).name; let harness = websocket_harness_with_provider_options( - provider, /*runtime_metrics_enabled*/ true, + provider, + /*runtime_metrics_enabled*/ true, /*concurrent_reasoning_summaries_enabled*/ true, + /*enabled_features*/ &[], ) .await; let mut client_session = harness.client.new_session(); @@ -1005,8 +1041,10 @@ async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() { let mut provider = websocket_provider(&server); provider.name = ModelProviderInfo::create_openai_provider(/*base_url*/ None).name; let harness = websocket_harness_with_provider_options( - provider, /*runtime_metrics_enabled*/ false, + provider, + /*runtime_metrics_enabled*/ false, /*concurrent_reasoning_summaries_enabled*/ false, + /*enabled_features*/ &[], ) .await; @@ -2216,6 +2254,7 @@ async fn websocket_harness_with_options( websocket_provider(server), runtime_metrics_enabled, /*concurrent_reasoning_summaries_enabled*/ false, + /*enabled_features*/ &[], ) .await } @@ -2224,6 +2263,7 @@ async fn websocket_harness_with_provider_options( provider: ModelProviderInfo, runtime_metrics_enabled: bool, concurrent_reasoning_summaries_enabled: bool, + enabled_features: &[Feature], ) -> WebsocketTestHarness { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home).await; @@ -2240,6 +2280,15 @@ async fn websocket_harness_with_provider_options( .enable(Feature::ConcurrentReasoningSummaries) .expect("test config should allow feature update"); } + for feature in enabled_features { + config + .features + .enable(*feature) + .expect("test config should allow feature update"); + } + config.respect_system_proxy = config.features.enabled(Feature::RespectSystemProxy); + let http_client_factory = config.http_client_factory(); + let outbound_proxy_policy = http_client_factory.outbound_proxy_policy(); let config = Arc::new(config); let model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config); let thread_id = ThreadId::new(); @@ -2284,12 +2333,13 @@ async fn websocket_harness_with_provider_options( .features .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, - config.http_client_factory(), + http_client_factory, ); WebsocketTestHarness { _codex_home: codex_home, client, + outbound_proxy_policy, session_id, thread_id, model_info, From 69d6997ddac2750580999801841d2dec5f710445 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 8 Jul 2026 13:07:25 -0700 Subject: [PATCH 2/5] model-provider: route model discovery through HTTP client factory --- codex-rs/core/src/test_support.rs | 8 ++++++- codex-rs/core/src/thread_manager.rs | 9 ++++++- codex-rs/memories/write/src/startup_tests.rs | 4 +++- .../model-provider/src/amazon_bedrock/mod.rs | 2 ++ codex-rs/model-provider/src/lib.rs | 1 + .../model-provider/src/models_endpoint.rs | 23 ++++++++++++++++-- codex-rs/model-provider/src/provider.rs | 24 +++++++++++++++---- 7 files changed, 62 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index 1f946602f5..8e8af7da5d 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -11,6 +11,8 @@ use codex_exec_server::EnvironmentManager; use codex_extension_api::LoadUserInstructionsFuture; use codex_extension_api::LoadedUserInstructions; use codex_extension_api::UserInstructionsProvider; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_model_provider::create_model_provider; @@ -155,7 +157,11 @@ pub fn models_manager_with_provider( provider: ModelProviderInfo, ) -> SharedModelsManager { let provider = create_model_provider(provider, Some(auth_manager)); - provider.models_manager(codex_home, /*config_model_catalog*/ None) + provider.models_manager( + codex_home, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + /*config_model_catalog*/ None, + ) } pub fn get_model_offline(model: Option<&str>) -> String { diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 669da49547..f22bdd9af9 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -32,6 +32,8 @@ use codex_extension_api::LoadedUserInstructions; use codex_extension_api::UserInstructionsProvider; use codex_extension_api::empty_extension_registry; use codex_features::Feature; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR; @@ -266,6 +268,7 @@ pub fn build_models_manager( let provider = create_model_provider(config.model_provider.clone(), Some(auth_manager)); provider.models_manager( config.codex_home.to_path_buf(), + config.http_client_factory(), config.model_catalog.clone(), ) } @@ -457,7 +460,11 @@ impl ThreadManager { threads: Arc::new(RwLock::new(HashMap::new())), thread_created_tx, models_manager: create_model_provider(provider, Some(auth_manager.clone())) - .models_manager(codex_home, /*config_model_catalog*/ None), + .models_manager( + codex_home, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + /*config_model_catalog*/ None, + ), environment_manager, skills_service, plugins_manager, diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index fad1ce14a4..b1e6ec492b 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -10,6 +10,7 @@ use codex_git_utils::diff_since_latest_init; use codex_git_utils::reset_git_repository; use codex_login::AuthManager; use codex_login::CodexAuth; +use codex_model_provider::HttpClientFactory; use codex_model_provider::ModelProvider; use codex_model_provider::ModelProviderFuture; use codex_model_provider::ProviderAccountResult; @@ -627,10 +628,11 @@ impl ModelProvider for MockMemoryModelProvider { fn models_manager( &self, codex_home: PathBuf, + http_client_factory: HttpClientFactory, config_model_catalog: Option, ) -> codex_models_manager::manager::SharedModelsManager { self.delegate - .models_manager(codex_home, config_model_catalog) + .models_manager(codex_home, http_client_factory, config_model_catalog) } } diff --git a/codex-rs/model-provider/src/amazon_bedrock/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index 9fdf937bcb..7568b535b2 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use codex_api::ApiError; use codex_api::Provider; use codex_api::SharedAuthProvider; +use codex_http_client::HttpClientFactory; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::auth::BedrockApiKeyAuth; @@ -165,6 +166,7 @@ impl ModelProvider for AmazonBedrockModelProvider { fn models_manager( &self, _codex_home: PathBuf, + _http_client_factory: HttpClientFactory, config_model_catalog: Option, ) -> SharedModelsManager { Arc::new(StaticModelsManager::new( diff --git a/codex-rs/model-provider/src/lib.rs b/codex-rs/model-provider/src/lib.rs index 15967c4fe0..771bf97b87 100644 --- a/codex-rs/model-provider/src/lib.rs +++ b/codex-rs/model-provider/src/lib.rs @@ -11,6 +11,7 @@ pub use auth::auth_provider_from_auth; pub use auth::unauthenticated_auth_provider; pub use bearer_auth_provider::BearerAuthProvider; pub use bearer_auth_provider::BearerAuthProvider as CoreAuthProvider; +pub use codex_http_client::HttpClientFactory; pub use codex_model_provider_info::CHATGPT_CODEX_BASE_URL; pub use codex_protocol::account::ProviderAccount; pub use provider::ModelProvider; diff --git a/codex-rs/model-provider/src/models_endpoint.rs b/codex-rs/model-provider/src/models_endpoint.rs index 9a203c26e1..1f04db72f3 100644 --- a/codex-rs/model-provider/src/models_endpoint.rs +++ b/codex-rs/model-provider/src/models_endpoint.rs @@ -10,11 +10,13 @@ use codex_api::auth_header_telemetry; use codex_api::map_api_error; use codex_feedback::FeedbackRequestTags; use codex_feedback::emit_feedback_request_tags_with_auth_env; +use codex_http_client::ClientRouteClass; +use codex_http_client::HttpClientFactory; use codex_login::AuthEnvTelemetry; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::collect_auth_env_telemetry; -use codex_login::default_client::build_reqwest_client; +use codex_login::default_client::build_default_reqwest_client_for_route; use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::manager::ModelsEndpointClient; use codex_models_manager::manager::ModelsEndpointFuture; @@ -38,16 +40,19 @@ const MODELS_ENDPOINT: &str = "/models"; pub(crate) struct OpenAiModelsEndpoint { provider_info: ModelProviderInfo, auth_manager: Option>, + http_client_factory: HttpClientFactory, } impl OpenAiModelsEndpoint { pub(crate) fn new( provider_info: ModelProviderInfo, auth_manager: Option>, + http_client_factory: HttpClientFactory, ) -> Self { Self { provider_info, auth_manager, + http_client_factory, } } @@ -75,7 +80,14 @@ impl OpenAiModelsEndpoint { let auth_mode = auth.as_ref().map(CodexAuth::auth_mode); let api_provider = self.provider_info.to_api_provider(auth_mode)?; let api_auth = resolve_provider_auth(auth.as_ref(), &self.provider_info)?; - let transport = ReqwestTransport::new(build_reqwest_client()); + let request_url = api_provider.url_for_path(MODELS_ENDPOINT); + let client = build_default_reqwest_client_for_route( + &self.http_client_factory, + &request_url, + ClientRouteClass::Api, + ) + .map_err(std::io::Error::from)?; + let transport = ReqwestTransport::new(client); let auth_telemetry = auth_header_telemetry(api_auth.as_ref()); let agent_identity_telemetry = if let Some(CodexAuth::AgentIdentity(auth)) = auth.as_ref() { Some(agent_identity_telemetry(auth)) @@ -229,8 +241,13 @@ mod tests { use std::num::NonZeroU64; use super::*; + use codex_http_client::OutboundProxyPolicy; use codex_protocol::config_types::ModelProviderAuthInfo; + fn default_http_client_factory() -> HttpClientFactory { + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault) + } + fn provider_info_with_command_auth() -> ModelProviderInfo { ModelProviderInfo { auth: Some(ModelProviderAuthInfo { @@ -253,6 +270,7 @@ mod tests { let endpoint = OpenAiModelsEndpoint::new( provider_info_with_command_auth(), /*auth_manager*/ None, + default_http_client_factory(), ); assert!(endpoint.has_command_auth()); @@ -263,6 +281,7 @@ mod tests { let endpoint = OpenAiModelsEndpoint::new( ModelProviderInfo::create_openai_provider(/*base_url*/ None), /*auth_manager*/ None, + default_http_client_factory(), ); assert!(!endpoint.has_command_auth()); diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 2b8cb67b97..ab059ce3c4 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use codex_api::ApiError; use codex_api::Provider; use codex_api::SharedAuthProvider; +use codex_http_client::HttpClientFactory; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; @@ -197,6 +198,7 @@ pub trait ModelProvider: fmt::Debug + Send + Sync { fn models_manager( &self, codex_home: PathBuf, + http_client_factory: HttpClientFactory, config_model_catalog: Option, ) -> SharedModelsManager; } @@ -314,6 +316,7 @@ impl ModelProvider for ConfiguredModelProvider { fn models_manager( &self, codex_home: PathBuf, + http_client_factory: HttpClientFactory, config_model_catalog: Option, ) -> SharedModelsManager { match config_model_catalog { @@ -325,6 +328,7 @@ impl ModelProvider for ConfiguredModelProvider { let endpoint = Arc::new(OpenAiModelsEndpoint::new( self.info.clone(), self.auth_manager.clone(), + http_client_factory, )); Arc::new(OpenAiModelsManager::new( codex_home, @@ -340,6 +344,7 @@ impl ModelProvider for ConfiguredModelProvider { mod tests { use std::num::NonZeroU64; + use codex_http_client::OutboundProxyPolicy; use codex_login::auth::AgentIdentityAuthPolicy; use codex_login::auth::BedrockApiKeyAuth; use codex_model_provider_info::ModelProviderAwsAuthInfo; @@ -363,6 +368,10 @@ mod tests { use super::*; use crate::auth::AgentIdentitySessionFallback; + fn default_http_client_factory() -> HttpClientFactory { + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault) + } + fn provider_info_with_command_auth() -> ModelProviderInfo { ModelProviderInfo { auth: Some(ModelProviderAuthInfo { @@ -653,8 +662,11 @@ mod tests { ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None), /*auth_manager*/ None, ); - let manager = - provider.models_manager(test_codex_home(), /*config_model_catalog*/ None); + let manager = provider.models_manager( + test_codex_home(), + default_http_client_factory(), + /*config_model_catalog*/ None, + ); let catalog = manager.raw_model_catalog(RefreshStrategy::Online).await; let model_ids = catalog @@ -701,6 +713,7 @@ mod tests { ); let manager = provider.models_manager( test_codex_home(), + default_http_client_factory(), Some(ModelsResponse { models: vec![configured_model], }), @@ -746,8 +759,11 @@ mod tests { )), ); - let manager = - provider.models_manager(test_codex_home(), /*config_model_catalog*/ None); + let manager = provider.models_manager( + test_codex_home(), + default_http_client_factory(), + /*config_model_catalog*/ None, + ); let catalog = manager.raw_model_catalog(RefreshStrategy::Online).await; assert!( From 0dfd1874d6c31baa3299c88ab6da2f93f4ca6207 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 8 Jul 2026 13:07:25 -0700 Subject: [PATCH 3/5] core: route realtime and memories through HTTP client factory --- codex-rs/core/src/client.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 2bd4ae3bcf..251b7c1dcf 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -70,7 +70,6 @@ use codex_login::CodexAuth; use codex_login::RefreshTokenError; use codex_login::UnauthorizedRecovery; use codex_login::default_client::build_default_reqwest_client_for_route; -use codex_login::default_client::build_reqwest_client; use codex_otel::SessionTelemetry; use codex_otel::current_span_w3c_trace_context; use codex_protocol::auth::AuthMode; @@ -155,6 +154,7 @@ const WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY: &str = const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; const X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER: &str = "x-openai-internal-codex-responses-lite"; +const REALTIME_CALLS_ENDPOINT: &str = "/realtime/calls"; const RESPONSES_ENDPOINT: &str = "/responses"; const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact"; // `/responses/compact` is unary, so the timeout covers the full response rather than one idle @@ -549,7 +549,7 @@ impl ModelClient { } let client_setup = self.current_client_setup().await?; let transport = - self.build_responses_transport(&client_setup.api_provider, RESPONSES_COMPACT_ENDPOINT)?; + self.build_api_transport(&client_setup.api_provider, RESPONSES_COMPACT_ENDPOINT)?; let request_telemetry = Self::build_request_telemetry( session_telemetry, AuthRequestTelemetryContext::new( @@ -651,8 +651,8 @@ impl ModelClient { sideband_headers.extend(sideband_websocket_auth_headers( client_setup.api_auth.as_ref(), )); - let transport = ReqwestTransport::new(build_reqwest_client()); let api_provider = api_provider_override.unwrap_or(client_setup.api_provider); + let transport = self.build_api_transport(&api_provider, REALTIME_CALLS_ENDPOINT)?; let response = ApiRealtimeCallClient::new(transport, api_provider, client_setup.api_auth) .create_with_session_and_headers(sdp, session_config, extra_headers) .await @@ -682,7 +682,8 @@ impl ModelClient { } let client_setup = self.current_client_setup().await?; - let transport = ReqwestTransport::new(build_reqwest_client()); + let transport = + self.build_api_transport(&client_setup.api_provider, MEMORIES_SUMMARIZE_ENDPOINT)?; let request_telemetry = Self::build_request_telemetry( session_telemetry, AuthRequestTelemetryContext::new( @@ -961,7 +962,7 @@ impl ModelClient { }) } - fn build_responses_transport( + fn build_api_transport( &self, api_provider: &ApiProvider, endpoint: &str, @@ -1413,7 +1414,7 @@ impl ModelClientSession { let client_setup = self.client.current_client_setup().await?; let transport = self .client - .build_responses_transport(&client_setup.api_provider, RESPONSES_ENDPOINT)?; + .build_api_transport(&client_setup.api_provider, RESPONSES_ENDPOINT)?; let request_auth_context = AuthRequestTelemetryContext::new( client_setup.auth.as_ref().map(CodexAuth::auth_mode), client_setup.api_auth.as_ref(), From 88c114a29dfb9b38872e25a2e502a28bfa064f07 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 8 Jul 2026 13:07:25 -0700 Subject: [PATCH 4/5] codex-api: route file uploads through HTTP client factory --- codex-rs/codex-api/src/files.rs | 107 +++++++++++++++++++-------- codex-rs/core/src/mcp_openai_file.rs | 2 + 2 files changed, 78 insertions(+), 31 deletions(-) diff --git a/codex-rs/codex-api/src/files.rs b/codex-rs/codex-api/src/files.rs index 3cd3932d06..c568555885 100644 --- a/codex-rs/codex-api/src/files.rs +++ b/codex-rs/codex-api/src/files.rs @@ -2,7 +2,10 @@ use std::time::Duration; use crate::AuthProvider; use bytes::Bytes; -use codex_http_client::build_reqwest_client_with_custom_ca; +use codex_http_client::BuildRouteAwareHttpClientError; +use codex_http_client::ClientRouteClass; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; use futures::Stream; use reqwest::StatusCode; use reqwest::header::CONTENT_LENGTH; @@ -55,6 +58,12 @@ pub enum OpenAiFileError { #[source] source: serde_json::Error, }, + #[error("failed to build OpenAI file client for {url}: {source}")] + ClientBuild { + url: String, + #[source] + source: BuildRouteAwareHttpClientError, + }, #[error("OpenAI file upload for `{file_id}` is not ready yet")] UploadNotReady { file_id: String }, #[error("OpenAI file upload for `{file_id}` failed: {message}")] @@ -84,6 +93,7 @@ pub fn openai_file_uri(file_id: &str) -> String { pub async fn upload_openai_file( base_url: &str, auth: &dyn AuthProvider, + http_client_factory: &HttpClientFactory, file_name: String, file_size_bytes: u64, contents: impl Stream> + Send + 'static, @@ -97,18 +107,23 @@ pub async fn upload_openai_file( } let create_url = format!("{}/files", base_url.trim_end_matches('/')); - let create_response = authorized_request(auth, reqwest::Method::POST, &create_url) - .json(&serde_json::json!({ - "file_name": file_name.as_str(), - "file_size": file_size_bytes, - "use_case": OPENAI_FILE_USE_CASE, - })) - .send() - .await - .map_err(|source| OpenAiFileError::Request { - url: create_url.clone(), - source, - })?; + let create_response = authorized_request( + http_client_factory, + auth, + reqwest::Method::POST, + &create_url, + )? + .json(&serde_json::json!({ + "file_name": file_name.as_str(), + "file_size": file_size_bytes, + "use_case": OPENAI_FILE_USE_CASE, + })) + .send() + .await + .map_err(|source| OpenAiFileError::Request { + url: create_url.clone(), + source, + })?; let create_status = create_response.status(); let create_body = create_response.text().await.unwrap_or_default(); if !create_status.is_success() { @@ -124,7 +139,7 @@ pub async fn upload_openai_file( source, })?; - let upload_response = build_reqwest_client() + let upload_response = build_reqwest_client(http_client_factory, &create_payload.upload_url)? .put(&create_payload.upload_url) .timeout(OPENAI_FILE_REQUEST_TIMEOUT) .header("x-ms-blob-type", "BlockBlob") @@ -153,14 +168,19 @@ pub async fn upload_openai_file( ); let finalize_started_at = Instant::now(); loop { - let finalize_response = authorized_request(auth, reqwest::Method::POST, &finalize_url) - .json(&serde_json::json!({})) - .send() - .await - .map_err(|source| OpenAiFileError::Request { - url: finalize_url.clone(), - source, - })?; + let finalize_response = authorized_request( + http_client_factory, + auth, + reqwest::Method::POST, + &finalize_url, + )? + .json(&serde_json::json!({})) + .send() + .await + .map_err(|source| OpenAiFileError::Request { + url: finalize_url.clone(), + source, + })?; let finalize_status = finalize_response.status(); let finalize_body = finalize_response.text().await.unwrap_or_default(); if !finalize_status.is_success() { @@ -213,25 +233,45 @@ pub async fn upload_openai_file( } fn authorized_request( + http_client_factory: &HttpClientFactory, auth: &dyn AuthProvider, method: reqwest::Method, url: &str, -) -> reqwest::RequestBuilder { +) -> Result { let mut headers = http::HeaderMap::new(); auth.add_auth_headers(&mut headers); - let client = build_reqwest_client(); - client + let client = build_reqwest_client(http_client_factory, url)?; + Ok(client .request(method, url) .timeout(OPENAI_FILE_REQUEST_TIMEOUT) - .headers(headers) + .headers(headers)) } -fn build_reqwest_client() -> reqwest::Client { - build_reqwest_client_with_custom_ca(reqwest::Client::builder()).unwrap_or_else(|error| { - tracing::warn!(error = %error, "failed to build OpenAI file upload client"); - reqwest::Client::new() - }) +fn build_reqwest_client( + http_client_factory: &HttpClientFactory, + url: &str, +) -> Result { + match http_client_factory.build_reqwest_client( + reqwest::Client::builder(), + url, + ClientRouteClass::Api, + ) { + Ok(client) => Ok(client), + Err(error) + if matches!( + http_client_factory.outbound_proxy_policy(), + OutboundProxyPolicy::ReqwestDefault + ) => + { + tracing::warn!(%error, "failed to build OpenAI file upload client"); + Ok(reqwest::Client::new()) + } + Err(source) => Err(OpenAiFileError::ClientBuild { + url: url.to_string(), + source, + }), + } } #[cfg(test)] @@ -254,6 +294,10 @@ mod tests { #[derive(Clone, Copy)] struct ChatGptTestAuth; + fn default_http_client_factory() -> HttpClientFactory { + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault) + } + impl AuthProvider for ChatGptTestAuth { fn add_auth_headers(&self, headers: &mut reqwest::header::HeaderMap) { headers.insert( @@ -324,6 +368,7 @@ mod tests { let uploaded = upload_openai_file( &base_url, &chatgpt_auth(), + &default_http_client_factory(), "hello.txt".to_string(), /*file_size_bytes*/ 5, contents, diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index 5044568cd6..462b27ffd8 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -159,9 +159,11 @@ async fn build_uploaded_argument_value( .unwrap_or("file") .to_string(); let upload_auth = codex_model_provider::auth_provider_from_auth(auth); + let http_client_factory = turn_context.config.http_client_factory(); let uploaded = upload_openai_file( turn_context.config.chatgpt_base_url.trim_end_matches('/'), upload_auth.as_ref(), + &http_client_factory, file_name, metadata.size, contents, From 8535ca6f8a9d4d705102e697a5c4f1cf8c2af684 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 8 Jul 2026 13:07:25 -0700 Subject: [PATCH 5/5] build: ratchet direct reqwest dependencies --- codex-rs/deny.toml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 4e52422f39..b15f7b2335 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -233,6 +233,39 @@ deny = [ "tonic", "zbus", ], reason = "first-party traits must use native async trait methods with explicit Send bounds" }, + # Migration ratchet: `codex-http-client` is the intended reqwest owner. Remove each temporary + # wrapper when that crate's direct reqwest dependency is migrated to the shared abstraction. + { crate = "reqwest", wrappers = [ + # Intended owner. + "codex-http-client", + # Temporary migration exceptions. + "codex-agent-identity", + "codex-api", + "codex-app-server", + "codex-app-server-daemon", + "codex-backend-client", + "codex-cloud-tasks", + "codex-core", + "codex-core-plugins", + "codex-exec-server", + "codex-lmstudio", + "codex-login", + "codex-ollama", + "codex-otel", + "codex-protocol", + "codex-responses-api-proxy", + "codex-rmcp-client", + "codex-tui", + "core_test_support", + # Third-party crates that own their reqwest integration. These are not part of the + # first-party migration count above. + "oauth2", + "opentelemetry-http", + "opentelemetry-otlp", + "rmcp", + "sentry", + "webrtc-sys-build", + ], reason = "new direct reqwest dependencies must use codex-http-client; temporary wrappers track migration debt" }, ] # List of features to allow/deny