core: route Responses API through system proxy

This commit is contained in:
Michael Bolin
2026-07-06 20:33:22 -07:00
parent 641aa1b619
commit c6c9f051a2
17 changed files with 166 additions and 38 deletions
+1
View File
@@ -2647,6 +2647,7 @@ dependencies = [
"codex-git-utils",
"codex-home",
"codex-hooks",
"codex-http-client",
"codex-image-generation-extension",
"codex-install-context",
"codex-login",
+1
View File
@@ -49,6 +49,7 @@ codex-shell-command = { workspace = true }
codex-execpolicy = { workspace = true }
codex-git-utils = { workspace = true }
codex-hooks = { workspace = true }
codex-http-client = { workspace = true }
codex-install-context = { workspace = true }
codex-network-proxy = { workspace = true }
codex-otel = { workspace = true }
+29 -3
View File
@@ -62,10 +62,13 @@ use codex_api::auth_header_telemetry;
use codex_api::build_session_headers;
use codex_api::create_text_param_for_request;
use codex_api::response_create_client_metadata;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientFactory;
use codex_login::AuthManager;
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;
@@ -248,6 +251,7 @@ pub struct ModelClient {
state: Arc<ModelClientState>,
agent_identity_policy: AgentIdentityAuthPolicy,
prompt_cache_key_override: Option<String>,
http_client_factory: HttpClientFactory,
}
/// A turn-scoped streaming session created from a [`ModelClient`].
@@ -396,7 +400,9 @@ impl ModelClient {
/// 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.
/// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly. The
/// HTTP client factory must come from the effective session configuration so every transport
/// observes the resolved outbound proxy policy.
pub fn new(
auth_manager: Option<Arc<AuthManager>>,
agent_identity_policy: AgentIdentityAuthPolicy,
@@ -410,6 +416,7 @@ impl ModelClient {
beta_features_header: Option<String>,
item_ids_enabled: bool,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
http_client_factory: HttpClientFactory,
) -> Self {
let model_provider = create_model_provider(provider_info, auth_manager);
let codex_api_key_env_enabled = model_provider
@@ -439,6 +446,7 @@ impl ModelClient {
}),
agent_identity_policy,
prompt_cache_key_override: None,
http_client_factory,
}
}
@@ -532,7 +540,8 @@ impl ModelClient {
return Ok(Vec::new());
}
let client_setup = self.current_client_setup().await?;
let transport = ReqwestTransport::new(build_reqwest_client());
let transport =
self.build_responses_transport(&client_setup.api_provider, RESPONSES_COMPACT_ENDPOINT)?;
let request_telemetry = Self::build_request_telemetry(
session_telemetry,
AuthRequestTelemetryContext::new(
@@ -938,6 +947,21 @@ impl ModelClient {
})
}
fn build_responses_transport(
&self,
api_provider: &ApiProvider,
endpoint: &str,
) -> Result<ReqwestTransport> {
let request_url = api_provider.url_for_path(endpoint);
let client = build_default_reqwest_client_for_route(
&self.http_client_factory,
&request_url,
ClientRouteClass::Api,
)
.map_err(std::io::Error::from)?;
Ok(ReqwestTransport::new(client))
}
pub(crate) async fn prewarm_auth(&self) -> Result<()> {
self.current_client_setup().await.map(|_| ())
}
@@ -1372,7 +1396,9 @@ impl ModelClientSession {
let mut pending_retry = PendingUnauthorizedRetry::default();
loop {
let client_setup = self.client.current_client_setup().await?;
let transport = ReqwestTransport::new(build_reqwest_client());
let transport = self
.client
.build_responses_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(),
+5
View File
@@ -19,6 +19,8 @@ use codex_api::AgentIdentityTelemetry;
use codex_api::ApiError;
use codex_api::ResponseEvent;
use codex_api::TransportError;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_login::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use codex_login::AuthManager;
@@ -104,6 +106,7 @@ fn test_model_client_with_thread_id(
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
/*attestation_provider*/ None,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
)
}
@@ -148,6 +151,7 @@ async fn compact_uses_bearer_after_agent_identity_session_fallback() -> anyhow::
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
/*attestation_provider*/ None,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
let prompt = Prompt {
input: vec![ResponseItem::Message {
@@ -824,6 +828,7 @@ fn model_client_with_counting_attestation(
Some(Arc::new(CountingAttestationProvider {
calls: attestation_calls.clone(),
})),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
(model_client, attestation_calls)
}
+4
View File
@@ -1590,6 +1590,10 @@ respect_system_proxy = true
.await?;
assert!(config.respect_system_proxy);
assert_eq!(
config.http_client_factory().outbound_proxy_policy(),
codex_http_client::OutboundProxyPolicy::RespectSystemProxy
);
Ok(())
}
+12
View File
@@ -70,6 +70,8 @@ use codex_features::MultiAgentV2ConfigToml;
use codex_features::NetworkProxyConfigToml;
use codex_features::TokenBudgetConfigToml;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_install_context::InstallContext;
use codex_login::AuthManagerConfig;
use codex_login::AuthRouteConfig;
@@ -1481,6 +1483,16 @@ impl Config {
.then(AuthRouteConfig::respect_system_proxy)
}
/// Creates the HTTP client factory resolved from the effective feature configuration.
pub fn http_client_factory(&self) -> HttpClientFactory {
let outbound_proxy_policy = if self.respect_system_proxy {
OutboundProxyPolicy::RespectSystemProxy
} else {
OutboundProxyPolicy::ReqwestDefault
};
HttpClientFactory::new(outbound_proxy_policy)
}
/// Build the plugin-manager input from the effective config.
pub fn plugins_config_input(&self) -> PluginsConfigInput {
PluginsConfigInput::new(
+1
View File
@@ -1112,6 +1112,7 @@ impl Session {
Self::build_model_client_beta_features_header(config.as_ref()),
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
attestation_provider,
config.http_client_factory(),
)
.with_prompt_cache_key_override(
crate::guardian::prompt_cache_key_override_for_review_session(
+5
View File
@@ -33,6 +33,8 @@ use codex_core_skills::HostSkillsSnapshot;
use core_test_support::test_codex::local_selections;
use codex_features::Feature;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_login::CodexAuth;
use codex_login::auth::AgentIdentityAuthPolicy;
use codex_model_provider_info::ModelProviderInfo;
@@ -491,6 +493,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession {
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
/*attestation_provider*/ None,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
)
.new_session()
}
@@ -5461,6 +5464,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
Session::build_model_client_beta_features_header(config.as_ref()),
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
/*attestation_provider*/ None,
config.http_client_factory(),
),
code_mode_service: crate::tools::code_mode::CodeModeService::new(Arc::new(
codex_code_mode::InProcessCodeModeSessionProvider,
@@ -7587,6 +7591,7 @@ where
Session::build_model_client_beta_features_header(config.as_ref()),
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
/*attestation_provider*/ None,
config.http_client_factory(),
),
code_mode_service: crate::tools::code_mode::CodeModeService::new(Arc::new(
codex_code_mode::InProcessCodeModeSessionProvider,
+3
View File
@@ -132,6 +132,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
/*attestation_provider*/ None,
config.http_client_factory(),
);
let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source);
let mut client_session = client.new_session();
@@ -266,6 +267,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
/*attestation_provider*/ None,
config.http_client_factory(),
);
let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source);
let mut client_session = client.new_session();
@@ -386,6 +388,7 @@ async fn responses_respects_model_info_overrides_from_config() {
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
/*attestation_provider*/ None,
config.http_client_factory(),
);
let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source);
let mut client_session = client.new_session();
+2
View File
@@ -1335,6 +1335,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth
/*beta_features_header*/ None,
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
/*attestation_provider*/ None,
config.http_client_factory(),
);
let responses_metadata = test_turn_responses_metadata(&client, thread_id);
let mut client_session = client.new_session();
@@ -2949,6 +2950,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
/*beta_features_header*/ None,
/*item_ids_enabled*/ false,
/*attestation_provider*/ None,
config.http_client_factory(),
);
let responses_metadata = test_turn_responses_metadata(&client, thread_id);
let mut client_session = client.new_session();
@@ -2252,6 +2252,7 @@ async fn websocket_harness_with_provider_options(
/*beta_features_header*/ None,
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
/*attestation_provider*/ None,
config.http_client_factory(),
);
WebsocketTestHarness {
+2 -2
View File
@@ -25,9 +25,9 @@ pub use crate::error::StreamError;
pub use crate::error::TransportError;
pub use crate::outbound_proxy::BuildRouteAwareHttpClientError;
pub use crate::outbound_proxy::ClientRouteClass;
pub use crate::outbound_proxy::OutboundProxyConfig;
pub use crate::outbound_proxy::HttpClientFactory;
pub use crate::outbound_proxy::OutboundProxyPolicy;
pub use crate::outbound_proxy::RouteFailureClass;
pub use crate::outbound_proxy::build_reqwest_client_for_route;
pub use crate::request::EncodedJsonBody;
pub use crate::request::PreparedRequestBody;
pub use crate::request::Request;
+51 -11
View File
@@ -84,14 +84,54 @@ impl fmt::Display for RouteFailureClass {
}
}
/// Marker enabling fixed system/PAC/WPAD, environment, then direct routing.
/// Resolved endpoints and platform details remain internal to the client builder.
/// Resolved outbound proxy behavior for HTTP clients.
///
/// Callers must choose a policy explicitly so omitting feature resolution cannot silently select
/// legacy behavior.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutboundProxyConfig;
pub enum OutboundProxyPolicy {
/// Preserve reqwest's built-in proxy behavior.
ReqwestDefault,
/// Resolve system/PAC/WPAD settings, then environment settings, then direct routing.
RespectSystemProxy,
}
impl OutboundProxyConfig {
pub const fn respect_system_proxy() -> Self {
Self
/// Builds route-specific HTTP clients using one resolved outbound proxy policy.
///
/// Construct this once from the effective application configuration and carry it with the
/// session or component that owns outbound requests. Individual request paths should supply only
/// their destination and route class rather than resolving feature state themselves.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpClientFactory {
outbound_proxy_policy: OutboundProxyPolicy,
}
impl HttpClientFactory {
/// Creates a factory from the outbound proxy policy resolved by the application.
pub const fn new(outbound_proxy_policy: OutboundProxyPolicy) -> Self {
Self {
outbound_proxy_policy,
}
}
/// Returns the outbound proxy policy used for clients built by this factory.
pub const fn outbound_proxy_policy(&self) -> OutboundProxyPolicy {
self.outbound_proxy_policy
}
/// Builds a reqwest client for a concrete outbound route.
pub fn build_reqwest_client(
&self,
builder: reqwest::ClientBuilder,
request_url: &str,
route_class: ClientRouteClass,
) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
build_reqwest_client_for_route(
builder,
request_url,
route_class,
self.outbound_proxy_policy,
)
}
}
@@ -120,18 +160,18 @@ impl From<BuildRouteAwareHttpClientError> for io::Error {
/// a route is selected are returned without trying another route. Ordered PAC candidates are
/// currently collapsed to one route on both Windows and macOS; later proxy or `DIRECT` candidates
/// are not retried after a connection failure.
pub fn build_reqwest_client_for_route(
fn build_reqwest_client_for_route(
builder: reqwest::ClientBuilder,
request_url: &str,
route_class: ClientRouteClass,
config: Option<&OutboundProxyConfig>,
outbound_proxy_policy: OutboundProxyPolicy,
) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
let builder = configure_proxy_for_route(
&ProcessEnv,
builder,
request_url,
route_class,
config,
outbound_proxy_policy,
resolve_system_proxy,
)?;
build_reqwest_client_with_custom_ca(builder).map_err(Into::into)
@@ -142,10 +182,10 @@ fn configure_proxy_for_route(
builder: reqwest::ClientBuilder,
request_url: &str,
route_class: ClientRouteClass,
config: Option<&OutboundProxyConfig>,
outbound_proxy_policy: OutboundProxyPolicy,
resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision,
) -> Result<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
if config.is_none() {
if matches!(outbound_proxy_policy, OutboundProxyPolicy::ReqwestDefault) {
return Ok(builder);
}
let origin = RequestOrigin::parse(request_url);
@@ -79,13 +79,12 @@ async fn enabled_environment_proxy_routes_request_through_proxy() {
values: HashMap::from([("HTTP_PROXY".to_string(), format!("http://{proxy_addr}"))]),
};
let request_url = "http://enabled-proxy.test/proxy-check";
let config = OutboundProxyConfig::respect_system_proxy();
let builder = configure_proxy_for_route(
&env,
reqwest::Client::builder().timeout(Duration::from_secs(2)),
request_url,
ClientRouteClass::Auth,
Some(&config),
OutboundProxyPolicy::RespectSystemProxy,
|_, _| SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
},
+41 -15
View File
@@ -8,8 +8,9 @@ use codex_http_client::BuildCustomCaTransportError;
use codex_http_client::BuildRouteAwareHttpClientError;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClient;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
pub use codex_http_client::RequestBuilder as CodexRequestBuilder;
use codex_http_client::build_reqwest_client_for_route;
use codex_http_client::build_reqwest_client_with_custom_ca;
use codex_http_client::with_chatgpt_cloudflare_cookie_store;
use codex_terminal_detection::user_agent;
@@ -235,6 +236,35 @@ pub fn try_build_reqwest_client() -> Result<reqwest::Client, BuildCustomCaTransp
build_reqwest_client_with_custom_ca(default_reqwest_client_builder())
}
/// Builds the default Codex reqwest client for a concrete outbound route.
///
/// When route-aware proxy handling is disabled, or the client is running inside the Codex
/// sandbox, this preserves the default client's existing proxy behavior. Otherwise it resolves
/// the destination through the shared system/PAC-aware routing policy.
pub fn build_default_reqwest_client_for_route(
http_client_factory: &HttpClientFactory,
request_url: &str,
route_class: ClientRouteClass,
) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
if matches!(
http_client_factory.outbound_proxy_policy(),
OutboundProxyPolicy::ReqwestDefault
) {
return Ok(build_reqwest_client());
}
if is_sandboxed() {
// Preserve the sandbox's existing no-proxy policy; sandboxed command egress is routed
// separately through network-proxy.
return Ok(build_reqwest_client());
}
http_client_factory.build_reqwest_client(
default_reqwest_client_builder(),
request_url,
route_class,
)
}
fn default_reqwest_client_builder() -> reqwest::ClientBuilder {
let mut builder = reqwest::Client::builder().default_headers(default_headers());
if is_sandboxed() {
@@ -248,11 +278,10 @@ pub(crate) fn build_raw_auth_reqwest_client(
endpoint: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
build_reqwest_client_for_route(
auth_http_client_factory(auth_route_config).build_reqwest_client(
reqwest::Client::builder(),
endpoint,
ClientRouteClass::Auth,
auth_route_config.map(AuthRouteConfig::route_config),
)
}
@@ -261,20 +290,10 @@ pub(crate) fn build_default_auth_reqwest_client(
endpoint: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
let Some(route_config) = auth_route_config.map(AuthRouteConfig::route_config) else {
return Ok(build_reqwest_client());
};
if is_sandboxed() {
// Preserve the sandbox's existing no-proxy policy; sandboxed command egress is routed
// separately through network-proxy.
return Ok(build_reqwest_client());
}
build_reqwest_client_for_route(
default_reqwest_client_builder(),
build_default_reqwest_client_for_route(
&auth_http_client_factory(auth_route_config),
endpoint,
ClientRouteClass::Auth,
Some(route_config),
)
}
@@ -286,6 +305,13 @@ pub(crate) fn create_default_auth_client(
build_default_auth_reqwest_client(endpoint, auth_route_config).map(HttpClient::new)
}
fn auth_http_client_factory(auth_route_config: Option<&AuthRouteConfig>) -> HttpClientFactory {
auth_route_config.map_or_else(
|| HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|config| config.http_client_factory().clone(),
)
}
pub fn default_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("originator", originator().header_value);
+6 -5
View File
@@ -1,4 +1,5 @@
use codex_http_client::OutboundProxyConfig;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
/// Auth-layer adapter around client-owned proxy policy.
///
@@ -6,17 +7,17 @@ use codex_http_client::OutboundProxyConfig;
/// client layer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthRouteConfig {
route_config: OutboundProxyConfig,
http_client_factory: HttpClientFactory,
}
impl AuthRouteConfig {
pub fn respect_system_proxy() -> Self {
Self {
route_config: OutboundProxyConfig::respect_system_proxy(),
http_client_factory: HttpClientFactory::new(OutboundProxyPolicy::RespectSystemProxy),
}
}
pub(crate) fn route_config(&self) -> &OutboundProxyConfig {
&self.route_config
pub(crate) fn http_client_factory(&self) -> &HttpClientFactory {
&self.http_client_factory
}
}
+1
View File
@@ -262,6 +262,7 @@ impl MemoryStartupContext {
/*beta_features_header*/ None,
config.features.enabled(Feature::ItemIds),
/*attestation_provider*/ None,
config.http_client_factory(),
);
let mut client_session = model_client.new_session();