diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index f03a3f2b37..e98d510242 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -885,8 +885,12 @@ impl AccountRequestProcessor { )); } - let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) - .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; + let client = BackendClient::from_auth_with_route_config( + self.config.chatgpt_base_url.clone(), + &auth, + self.auth_manager.auth_route_config(), + ) + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; let profile = tokio::time::timeout( ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT, client.get_token_usage_profile(), @@ -944,8 +948,12 @@ impl AccountRequestProcessor { )); } - let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) - .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; + let client = BackendClient::from_auth_with_route_config( + self.config.chatgpt_base_url.clone(), + &auth, + self.auth_manager.auth_route_config(), + ) + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; match client .send_add_credits_nudge_email(Self::backend_credit_type(params.credit_type)) @@ -989,8 +997,12 @@ impl AccountRequestProcessor { )); } - let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) - .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; + let client = BackendClient::from_auth_with_route_config( + self.config.chatgpt_base_url.clone(), + &auth, + self.auth_manager.auth_route_config(), + ) + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; let snapshots = client .get_rate_limits_many() diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index e4971b36fa..ca1f046bb9 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -485,6 +485,13 @@ impl PluginRequestProcessor { self.thread_manager.skills_manager().clear_cache(); } + fn remote_plugin_service_config(&self, config: &Config) -> RemotePluginServiceConfig { + RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + auth_route_config: self.auth_manager.auth_route_config().cloned(), + } + } + async fn load_latest_config( &self, fallback_cwd: Option, @@ -554,9 +561,7 @@ impl PluginRequestProcessor { marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe); let include_global_remote = !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin); - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = self.remote_plugin_service_config(&config); let refresh_global_remote_catalog_cache = include_global_remote && codex_core_plugins::remote::has_cached_global_remote_plugin_catalog( config.codex_home.as_path(), @@ -990,9 +995,8 @@ impl PluginRequestProcessor { let share_context = match share_context { Some(context) => { let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = + self.remote_plugin_service_config(&config); match codex_core_plugins::remote::fetch_remote_plugin_share_context( &remote_plugin_service_config, auth.as_ref(), @@ -1096,9 +1100,7 @@ impl PluginRequestProcessor { ))); } let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = self.remote_plugin_service_config(&config); validate_remote_plugin_id(&plugin_name)?; let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( &remote_plugin_service_config, @@ -1154,9 +1156,7 @@ impl PluginRequestProcessor { } let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = self.remote_plugin_service_config(&config); let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail( &remote_plugin_service_config, auth.as_ref(), @@ -1207,9 +1207,7 @@ impl PluginRequestProcessor { validate_client_plugin_share_targets(share_targets)?; } - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = self.remote_plugin_service_config(&config); let access_policy = codex_core_plugins::remote::RemotePluginShareAccessPolicy { discoverability: discoverability.map(remote_plugin_share_discoverability), share_targets: share_targets.map(remote_plugin_share_targets), @@ -1250,9 +1248,7 @@ impl PluginRequestProcessor { } validate_client_plugin_share_targets(&share_targets)?; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = self.remote_plugin_service_config(&config); let result = codex_core_plugins::remote::update_remote_plugin_share_targets( &remote_plugin_service_config, auth.as_ref(), @@ -1280,9 +1276,7 @@ impl PluginRequestProcessor { _params: PluginShareListParams, ) -> Result { let (config, auth) = self.load_plugin_share_config_and_auth().await?; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = self.remote_plugin_service_config(&config); let data = codex_core_plugins::remote::list_remote_plugin_shares( &remote_plugin_service_config, auth.as_ref(), @@ -1319,9 +1313,7 @@ impl PluginRequestProcessor { return Err(invalid_request("invalid remote plugin id")); } - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = self.remote_plugin_service_config(&config); let result = codex_core_plugins::remote::checkout_remote_plugin_share( &remote_plugin_service_config, auth.as_ref(), @@ -1352,9 +1344,7 @@ impl PluginRequestProcessor { return Err(invalid_request("invalid remote plugin id")); } - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = self.remote_plugin_service_config(&config); codex_core_plugins::remote::delete_remote_plugin_share( &remote_plugin_service_config, auth.as_ref(), @@ -1472,9 +1462,7 @@ impl PluginRequestProcessor { validate_remote_plugin_id(&remote_plugin_id)?; let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = self.remote_plugin_service_config(&config); let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail_with_download_urls( &remote_plugin_service_config, @@ -1521,6 +1509,7 @@ impl PluginRequestProcessor { let result = codex_core_plugins::remote_bundle::download_and_install_remote_plugin_bundle( config.codex_home.to_path_buf(), validated_bundle, + remote_plugin_service_config.auth_route_config.as_ref(), ) .await .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; @@ -1859,9 +1848,7 @@ impl PluginRequestProcessor { validate_remote_plugin_id(&plugin_id)?; let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = self.remote_plugin_service_config(&config); let uninstall_result = codex_core_plugins::remote::uninstall_remote_plugin( &remote_plugin_service_config, auth.as_ref(), @@ -2118,6 +2105,7 @@ fn remote_plugin_catalog_error_to_jsonrpc( | RemotePluginCatalogError::ArchiveTooLarge { .. } | RemotePluginCatalogError::UnknownMarketplace { .. } => invalid_request(message), RemotePluginCatalogError::AuthToken(_) + | RemotePluginCatalogError::HttpClient { .. } | RemotePluginCatalogError::Request { .. } | RemotePluginCatalogError::UnexpectedStatus { .. } | RemotePluginCatalogError::Decode { .. } diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index b15aec7e0f..5ee53c0aa9 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -13,6 +13,7 @@ use codex_client::OutboundProxyConfig; use codex_client::build_reqwest_client_for_route; use codex_client::build_reqwest_client_with_custom_ca; use codex_client::with_chatgpt_cloudflare_cookie_store; +use codex_login::AuthRouteConfig; use codex_login::CodexAuth; use codex_login::default_client::get_codex_user_agent; use codex_protocol::account::PlanType as AccountPlanType; @@ -189,7 +190,23 @@ impl Client { } pub fn from_auth(base_url: impl Into, auth: &CodexAuth) -> Result { - Ok(Self::new(base_url)? + Self::from_auth_with_route_config(base_url, auth, /*auth_route_config*/ None) + } + + /// Creates an authenticated backend client using the configured outbound route policy. + pub fn from_auth_with_route_config( + base_url: impl Into, + auth: &CodexAuth, + auth_route_config: Option<&AuthRouteConfig>, + ) -> Result { + let base_url = normalize_base_url(base_url.into()); + let http = build_reqwest_client_for_route( + with_chatgpt_cloudflare_cookie_store(reqwest::Client::builder()), + &base_url, + ClientRouteClass::Api, + auth_route_config.and_then(AuthRouteConfig::route_config), + )?; + Ok(Self::with_http_client(base_url, http) .with_user_agent(get_codex_user_agent()) .with_auth_provider(codex_model_provider::auth_provider_from_auth(auth))) } diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index 372f62e696..63861344c8 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -1,6 +1,7 @@ use codex_core::config::Config; use codex_login::AuthManager; -use codex_login::default_client::create_client; +use codex_login::default_client::ClientRouteClass; +use codex_login::default_client::create_client_for_route; use anyhow::Context; use serde::de::DeserializeOwned; @@ -38,13 +39,17 @@ pub(crate) async fn chatgpt_get_request_with_timeout( "ChatGPT account ID not available, please re-run `codex login`" ); - // Make direct HTTP request to ChatGPT backend API with the token - let client = create_client(); let url = format!( "{}/{}", chatgpt_base_url.trim_end_matches('/'), path.trim_start_matches('/') ); + let client = create_client_for_route( + &url, + ClientRouteClass::Api, + auth_manager.auth_route_config(), + ) + .context("Failed to configure ChatGPT backend client")?; let mut request = client .get(&url) diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 59bf365487..0d09522157 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -58,6 +58,7 @@ use codex_core_skills::config_rules::SkillConfigRules; use codex_core_skills::config_rules::skill_config_rules_from_stack; use codex_hooks::plugin_hook_declarations; use codex_login::AuthManager; +use codex_login::AuthRouteConfig; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; @@ -89,6 +90,7 @@ pub struct PluginsConfigInput { pub plugins_enabled: bool, pub remote_plugin_enabled: bool, pub chatgpt_base_url: String, + pub auth_route_config: Option, } impl PluginsConfigInput { @@ -97,12 +99,14 @@ impl PluginsConfigInput { plugins_enabled: bool, remote_plugin_enabled: bool, chatgpt_base_url: String, + auth_route_config: Option, ) -> Self { Self { config_layer_stack, plugins_enabled, remote_plugin_enabled, chatgpt_base_url, + auth_route_config, } } } @@ -189,6 +193,7 @@ struct ConfiguredMarketplaceUpgradeState { fn remote_plugin_service_config(config: &PluginsConfigInput) -> RemotePluginServiceConfig { RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), + auth_route_config: config.auth_route_config.clone(), } } @@ -1333,7 +1338,7 @@ impl PluginsManager { on_effective_plugins_changed: Option>, ) { if config.plugins_enabled { - self.start_curated_repo_sync(); + self.start_curated_repo_sync(config.auth_route_config.clone()); let should_spawn_marketplace_auto_upgrade = { let mut state = match self.configured_marketplace_upgrade_state.write() { Ok(state) => state, @@ -1630,7 +1635,7 @@ impl PluginsManager { } } - fn start_curated_repo_sync(self: &Arc) { + fn start_curated_repo_sync(self: &Arc, auth_route_config: Option) { if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) { return; } @@ -1638,8 +1643,8 @@ impl PluginsManager { let codex_home = self.codex_home.clone(); if let Err(err) = std::thread::Builder::new() .name("plugins-curated-repo-sync".to_string()) - .spawn( - move || match sync_openai_plugins_repo(codex_home.as_path()) { + .spawn(move || { + match sync_openai_plugins_repo(codex_home.as_path(), auth_route_config.as_ref()) { Ok(curated_plugin_version) => { let configured_curated_plugin_ids = configured_curated_plugin_ids_from_codex_home(codex_home.as_path()); @@ -1664,8 +1669,8 @@ impl PluginsManager { CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); warn!("failed to sync curated plugins repo: {err}"); } - }, - ) + } + }) { CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); warn!("failed to start curated plugins repo sync task: {err}"); diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 1a268a4888..81018b7849 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -1412,6 +1412,7 @@ async fn plugin_cache_ignores_unrelated_session_overrides() { /*plugins_enabled*/ true, /*remote_plugin_enabled*/ false, "https://chatgpt.com".to_string(), + /*auth_route_config*/ None, ) }; let manager = PluginsManager::new(codex_home.path().to_path_buf()); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index b948c51733..52b1d049ac 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -6,8 +6,10 @@ use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::SkillInterface; +use codex_login::AuthRouteConfig; use codex_login::CodexAuth; -use codex_login::default_client::build_reqwest_client; +use codex_login::default_client::ClientRouteClass; +use codex_login::default_client::build_default_reqwest_client_for_route; use codex_plugin::PluginId; use codex_utils_absolute_path::AbsolutePathBuf; use reqwest::RequestBuilder; @@ -97,6 +99,30 @@ const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 5] = [ #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginServiceConfig { pub chatgpt_base_url: String, + pub auth_route_config: Option, +} + +impl RemotePluginServiceConfig { + pub(crate) fn build_http_client(&self, url: &str) -> anyhow::Result { + build_default_reqwest_client_for_route( + url, + ClientRouteClass::Api, + self.auth_route_config.as_ref(), + ) + .map_err(Into::into) + } +} + +fn build_remote_plugin_http_client( + config: &RemotePluginServiceConfig, + url: &str, +) -> Result { + config + .build_http_client(url) + .map_err(|source| RemotePluginCatalogError::HttpClient { + url: url.to_string(), + source, + }) } #[derive(Debug, Clone, PartialEq)] @@ -253,6 +279,13 @@ pub enum RemotePluginCatalogError { #[error("failed to read auth token for remote plugin catalog: {0}")] AuthToken(#[source] std::io::Error), + #[error("failed to configure remote plugin catalog client for {url}: {source}")] + HttpClient { + url: String, + #[source] + source: anyhow::Error, + }, + #[error("failed to send remote plugin catalog request to {url}: {source}")] Request { url: String, @@ -959,7 +992,7 @@ pub async fn fetch_remote_plugin_skill_detail( } let url = remote_plugin_skill_detail_url(config, plugin_id, skill_name)?; - let client = build_reqwest_client(); + let client = build_remote_plugin_http_client(config, &url)?; let request = authenticated_request(client.get(&url), auth)?; let response: RemotePluginSkillDetailResponse = send_and_decode(request, &url).await?; if response.plugin_id != plugin_id { @@ -1086,7 +1119,7 @@ pub async fn install_remote_plugin( let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{plugin_id}/install"); - let client = build_reqwest_client(); + let client = build_remote_plugin_http_client(config, &url)?; let request = authenticated_request( client .post(&url) @@ -1129,7 +1162,7 @@ pub async fn uninstall_remote_plugin( let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{plugin_id}/uninstall"); - let client = build_reqwest_client(); + let client = build_remote_plugin_http_client(config, &url)?; let request = authenticated_request(client.post(&url), auth)?; let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; if response.id != plugin_id { @@ -1535,7 +1568,7 @@ async fn get_remote_plugin_list_page( ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/list"); - let client = build_reqwest_client(); + let client = build_remote_plugin_http_client(config, &url)?; let mut request = authenticated_request(client.get(&url), auth)?; request = request.query(&[("scope", scope.api_value())]); request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); @@ -1555,7 +1588,7 @@ async fn get_remote_shared_workspace_plugins_page( ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/workspace/shared"); - let client = build_reqwest_client(); + let client = build_remote_plugin_http_client(config, &url)?; let mut request = authenticated_request(client.get(&url), auth)?; request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); if let Some(page_token) = page_token { @@ -1573,7 +1606,7 @@ async fn get_remote_plugin_installed_page( ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/installed"); - let client = build_reqwest_client(); + let client = build_remote_plugin_http_client(config, &url)?; let mut request = authenticated_request(client.get(&url), auth)?; request = request.query(&[("scope", scope.api_value())]); if include_download_urls { @@ -1593,7 +1626,7 @@ async fn fetch_plugin_detail( ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{plugin_id}"); - let client = build_reqwest_client(); + let client = build_remote_plugin_http_client(config, &url)?; let mut request = authenticated_request(client.get(&url), auth)?; if include_download_urls { request = request.query(&[("includeDownloadUrls", true)]); diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index fe3a597b0f..aa9986c753 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -227,6 +227,7 @@ pub async fn sync_remote_installed_plugin_bundles_once( match crate::remote_bundle::download_and_install_remote_plugin_bundle( codex_home.clone(), bundle, + config.auth_route_config.as_ref(), ) .await { diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index c400d27c47..59d729fd03 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -2,7 +2,6 @@ use super::*; use crate::plugin_bundle_archive::PluginBundlePackError; use crate::plugin_bundle_archive::pack_plugin_bundle_tar_gz; use codex_login::CodexAuth; -use codex_login::default_client::build_reqwest_client; use codex_utils_absolute_path::AbsolutePathBuf; use reqwest::RequestBuilder; use reqwest::StatusCode; @@ -163,7 +162,7 @@ pub async fn save_remote_plugin_share( let etag = upload .etag .ok_or(RemotePluginCatalogError::MissingUploadEtag)?; - put_workspace_plugin_upload(&upload.upload_url, archive_bytes).await?; + put_workspace_plugin_upload(config, &upload.upload_url, archive_bytes).await?; let share_targets = access_policy.share_targets; let share_targets = ensure_unlisted_workspace_target(auth, access_policy.discoverability, share_targets)?; @@ -279,7 +278,7 @@ pub async fn delete_remote_plugin_share( let auth = ensure_chatgpt_auth(auth)?; let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/public/plugins/workspace/{remote_plugin_id}"); - let client = build_reqwest_client(); + let client = build_remote_plugin_http_client(config, &url)?; let request = authenticated_request(client.delete(&url), auth)?; send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await?; if let Err(err) = local_paths::remove_plugin_share_local_path(codex_home, remote_plugin_id) { @@ -312,7 +311,7 @@ pub async fn update_remote_plugin_share_targets( .unwrap_or_default(); let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{remote_plugin_id}/shares"); - let client = build_reqwest_client(); + let client = build_remote_plugin_http_client(config, &url)?; let request = authenticated_request(client.put(&url), auth)?.json( &RemotePluginShareUpdateTargetsRequest { discoverability, @@ -378,7 +377,7 @@ async fn get_created_workspace_plugins_page( ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/workspace/created"); - let client = build_reqwest_client(); + let client = build_remote_plugin_http_client(config, &url)?; let mut request = authenticated_request(client.get(&url), auth)?; request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); if let Some(page_token) = page_token { @@ -396,7 +395,7 @@ async fn create_workspace_plugin_upload( ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/public/plugins/workspace/upload-url"); - let client = build_reqwest_client(); + let client = build_remote_plugin_http_client(config, &url)?; let request = authenticated_request(client.post(&url), auth)?.json( &RemoteWorkspacePluginUploadUrlRequest { filename, @@ -409,10 +408,16 @@ async fn create_workspace_plugin_upload( } async fn put_workspace_plugin_upload( + config: &RemotePluginServiceConfig, upload_url: &str, archive_bytes: Vec, ) -> Result<(), RemotePluginCatalogError> { - let client = build_reqwest_client(); + let client = config.build_http_client(upload_url).map_err(|source| { + RemotePluginCatalogError::HttpClient { + url: "workspace plugin upload URL".to_string(), + source, + } + })?; let request = client .put(upload_url) .timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT) @@ -450,7 +455,7 @@ async fn finalize_workspace_plugin_upload( } else { format!("{base_url}/public/plugins/workspace") }; - let client = build_reqwest_client(); + let client = build_remote_plugin_http_client(config, &url)?; let request = authenticated_request(client.post(&url), auth)?.json(&body); send_and_decode(request, &url).await } diff --git a/codex-rs/core-plugins/src/remote/share/checkout.rs b/codex-rs/core-plugins/src/remote/share/checkout.rs index 6b12d2588d..a71c4db9da 100644 --- a/codex-rs/core-plugins/src/remote/share/checkout.rs +++ b/codex-rs/core-plugins/src/remote/share/checkout.rs @@ -95,6 +95,7 @@ pub async fn checkout_remote_plugin_share( crate::remote_bundle::download_and_extract_remote_plugin_bundle_to_path( bundle, local_plugin_path.clone(), + config.auth_route_config.as_ref(), ) .await .map_err(|err| { diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index e33d3c5d51..28befe76b6 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -25,6 +25,7 @@ use wiremock::matchers::query_param_is_missing; fn test_config(server: &MockServer) -> RemotePluginServiceConfig { RemotePluginServiceConfig { chatgpt_base_url: format!("{}/backend-api", server.uri()), + auth_route_config: None, } } diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index cb60d60b77..c4c2a7a9e4 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -5,7 +5,9 @@ use crate::store::PluginInstallResult; use crate::store::PluginStore; use crate::store::PluginStoreError; use crate::store::validate_plugin_version_segment; -use codex_login::default_client::build_reqwest_client; +use codex_login::AuthRouteConfig; +use codex_login::default_client::ClientRouteClass; +use codex_login::default_client::build_default_reqwest_client_for_route; use codex_plugin::PluginId; use codex_plugin::PluginIdError; use codex_utils_absolute_path::AbsolutePathBuf; @@ -88,6 +90,13 @@ pub enum RemotePluginBundleInstallError { source: reqwest::Error, }, + #[error("failed to configure remote plugin bundle client for {url}: {source}")] + DownloadClient { + url: String, + #[source] + source: anyhow::Error, + }, + #[error("remote plugin bundle download from {url} failed with status {status}: {body}")] DownloadStatus { url: String, @@ -226,10 +235,12 @@ fn is_loopback_url(url: &Url) -> bool { pub async fn download_and_install_remote_plugin_bundle( codex_home: PathBuf, bundle: ValidatedRemotePluginBundle, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result { let bundle_bytes = download_remote_plugin_bundle_with_limit( &bundle.bundle_download_url, /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES, + auth_route_config, ) .await?; tokio::task::spawn_blocking(move || { @@ -246,10 +257,12 @@ pub async fn download_and_install_remote_plugin_bundle( pub(crate) async fn download_and_extract_remote_plugin_bundle_to_path( bundle: ValidatedRemotePluginBundle, destination: AbsolutePathBuf, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result { let bundle_bytes = download_remote_plugin_bundle_with_limit( &bundle.bundle_download_url, /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES, + auth_route_config, ) .await?; tokio::task::spawn_blocking(move || { @@ -266,8 +279,17 @@ pub(crate) async fn download_and_extract_remote_plugin_bundle_to_path( async fn download_remote_plugin_bundle_with_limit( bundle_download_url: &str, max_bytes: u64, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result, RemotePluginBundleInstallError> { - let client = build_reqwest_client(); + let client = build_default_reqwest_client_for_route( + bundle_download_url, + ClientRouteClass::Api, + auth_route_config, + ) + .map_err(|source| RemotePluginBundleInstallError::DownloadClient { + url: bundle_download_url.to_string(), + source: source.into(), + })?; let response = client .get(bundle_download_url) .timeout(REMOTE_PLUGIN_BUNDLE_DOWNLOAD_TIMEOUT) diff --git a/codex-rs/core-plugins/src/remote_legacy.rs b/codex-rs/core-plugins/src/remote_legacy.rs index 137c33753b..c2ca834cf7 100644 --- a/codex-rs/core-plugins/src/remote_legacy.rs +++ b/codex-rs/core-plugins/src/remote_legacy.rs @@ -1,6 +1,5 @@ use crate::remote::RemotePluginServiceConfig; use codex_login::CodexAuth; -use codex_login::default_client::build_reqwest_client; use codex_protocol::protocol::Product; use serde::Deserialize; use std::time::Duration; @@ -35,6 +34,13 @@ pub enum RemotePluginMutationError { #[error("chatgpt base url cannot be used for plugin mutation")] InvalidBaseUrlPath, + #[error("failed to configure remote plugin mutation client for {url}: {source}")] + HttpClient { + url: String, + #[source] + source: anyhow::Error, + }, + #[error("failed to send remote plugin mutation request to {url}: {source}")] Request { url: String, @@ -73,6 +79,13 @@ pub enum RemotePluginMutationError { #[derive(Debug, thiserror::Error)] pub enum RemotePluginFetchError { + #[error("failed to configure remote featured plugin client for {url}: {source}")] + HttpClient { + url: String, + #[source] + source: anyhow::Error, + }, + #[error("failed to send remote featured plugin request to {url}: {source}")] Request { url: String, @@ -102,7 +115,13 @@ pub async fn fetch_remote_featured_plugin_ids( ) -> Result, RemotePluginFetchError> { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/plugins/featured"); - let client = build_reqwest_client(); + let client = + config + .build_http_client(&url) + .map_err(|source| RemotePluginFetchError::HttpClient { + url: url.clone(), + source, + })?; let mut request = client .get(&url) .query(&[( @@ -173,7 +192,13 @@ async fn post_remote_plugin_mutation( ) -> Result { let auth = ensure_codex_backend_auth(auth)?; let url = remote_plugin_mutation_url(config, plugin_id, action)?; - let client = build_reqwest_client(); + let client = + config + .build_http_client(&url) + .map_err(|source| RemotePluginMutationError::HttpClient { + url: url.clone(), + source, + })?; let request = client .post(url.clone()) .timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT) diff --git a/codex-rs/core-plugins/src/startup_sync.rs b/codex-rs/core-plugins/src/startup_sync.rs index c5965f2212..f470f0625b 100644 --- a/codex-rs/core-plugins/src/startup_sync.rs +++ b/codex-rs/core-plugins/src/startup_sync.rs @@ -6,6 +6,9 @@ use std::process::Output; use std::process::Stdio; use std::time::Duration; +use codex_login::AuthRouteConfig; +use codex_login::default_client::ClientRouteClass; +use codex_login::default_client::build_default_reqwest_client_for_route; use codex_otel::CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC; use codex_otel::CURATED_PLUGINS_STARTUP_SYNC_METRIC; use reqwest::Client; @@ -14,8 +17,6 @@ use tempfile::TempDir; use tracing::warn; use zip::ZipArchive; -use codex_login::default_client::build_reqwest_client; - const GITHUB_API_BASE_URL: &str = "https://api.github.com"; const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json"; const GITHUB_API_VERSION_HEADER: &str = "2022-11-28"; @@ -67,12 +68,16 @@ fn curated_plugins_sha_path(codex_home: &Path) -> PathBuf { codex_home.join(CURATED_PLUGINS_SHA_FILE) } -pub fn sync_openai_plugins_repo(codex_home: &Path) -> Result { +pub fn sync_openai_plugins_repo( + codex_home: &Path, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { sync_openai_plugins_repo_with_transport_overrides( codex_home, "git", GITHUB_API_BASE_URL, CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL, + auth_route_config, ) } @@ -81,23 +86,33 @@ fn sync_openai_plugins_repo_with_transport_overrides( git_binary: &str, api_base_url: &str, backup_archive_api_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result { let _file_guard = lock_curated_plugins_startup_sync(codex_home)?; - match sync_openai_plugins_repo_via_git(codex_home, git_binary) { + let route_aware_http = + auth_route_config.is_some_and(AuthRouteConfig::requires_route_aware_transport); + let git_result = if route_aware_http { + Err("route-aware plugin sync requires the HTTP transport".to_string()) + } else { + sync_openai_plugins_repo_via_git(codex_home, git_binary) + }; + match git_result { Ok(remote_sha) => { emit_curated_plugins_startup_sync_metric("git", "success"); emit_curated_plugins_startup_sync_final_metric("git", "success"); Ok(remote_sha) } Err(err) => { - emit_curated_plugins_startup_sync_metric("git", "failure"); - warn!( - error = %err, - git_binary, - "git sync failed for curated plugin sync; falling back to GitHub HTTP" - ); - match sync_openai_plugins_repo_via_http(codex_home, api_base_url) { + if !route_aware_http { + emit_curated_plugins_startup_sync_metric("git", "failure"); + warn!( + error = %err, + git_binary, + "git sync failed for curated plugin sync; falling back to GitHub HTTP" + ); + } + match sync_openai_plugins_repo_via_http(codex_home, api_base_url, auth_route_config) { Ok(remote_sha) => { emit_curated_plugins_startup_sync_metric("http", "success"); emit_curated_plugins_startup_sync_final_metric("http", "success"); @@ -125,6 +140,7 @@ fn sync_openai_plugins_repo_with_transport_overrides( let result = sync_openai_plugins_repo_via_backup_archive( codex_home, backup_archive_api_url, + auth_route_config, ); let status = if result.is_ok() { "success" } else { "failure" }; emit_curated_plugins_startup_sync_metric("export_archive", status); @@ -288,6 +304,7 @@ fn run_git_in_repo( fn sync_openai_plugins_repo_via_http( codex_home: &Path, api_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result { let repo_path = curated_plugins_repo_path(codex_home); let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); @@ -295,7 +312,10 @@ fn sync_openai_plugins_repo_via_http( .enable_all() .build() .map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?; - let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha(api_base_url))?; + let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha( + api_base_url, + auth_route_config, + ))?; let local_sha = read_sha_file(&sha_path); if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.is_dir() { @@ -303,7 +323,11 @@ fn sync_openai_plugins_repo_via_http( } let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; - let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?; + let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball( + api_base_url, + &remote_sha, + auth_route_config, + ))?; extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?; ensure_marketplace_manifest_exists(staged_repo_dir.path())?; activate_curated_repo(&repo_path, staged_repo_dir)?; @@ -314,6 +338,7 @@ fn sync_openai_plugins_repo_via_http( fn sync_openai_plugins_repo_via_backup_archive( codex_home: &Path, backup_archive_api_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result { let repo_path = curated_plugins_repo_path(codex_home); let sha_path = curated_plugins_sha_path(codex_home); @@ -324,6 +349,7 @@ fn sync_openai_plugins_repo_via_backup_archive( let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; let zipball_bytes = runtime.block_on(fetch_curated_repo_backup_archive_zip( backup_archive_api_url, + auth_route_config, ))?; extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?; ensure_marketplace_manifest_exists(staged_repo_dir.path())?; @@ -700,10 +726,13 @@ fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> { } } -async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result { +async fn fetch_curated_repo_remote_sha( + api_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { let api_base_url = api_base_url.trim_end_matches('/'); let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); - let client = build_reqwest_client(); + let client = build_startup_sync_client(&repo_url, auth_route_config)?; let repo_body = fetch_github_text(&client, &repo_url, "get curated plugins repository").await?; let repo_summary: GitHubRepositorySummary = serde_json::from_str(&repo_body).map_err(|err| { @@ -733,18 +762,20 @@ async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result, ) -> Result, String> { let api_base_url = api_base_url.trim_end_matches('/'); let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); let zipball_url = format!("{repo_url}/zipball/{remote_sha}"); - let client = build_reqwest_client(); + let client = build_startup_sync_client(&zipball_url, auth_route_config)?; fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await } async fn fetch_curated_repo_backup_archive_zip( backup_archive_api_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result, String> { - let client = build_reqwest_client(); + let client = build_startup_sync_client(backup_archive_api_url, auth_route_config)?; let export_body = fetch_public_text( &client, backup_archive_api_url, @@ -763,14 +794,24 @@ async fn fetch_curated_repo_backup_archive_zip( )); } + let download_client = + build_startup_sync_client(&export_response.download_url, auth_route_config)?; fetch_public_bytes( - &client, + &download_client, &export_response.download_url, "download curated plugins export archive", ) .await } +fn build_startup_sync_client( + url: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + build_default_reqwest_client_for_route(url, ClientRouteClass::Api, auth_route_config) + .map_err(|err| format!("failed to configure curated plugins HTTP client: {err}")) +} + fn read_extracted_backup_archive_git_sha(repo_path: &Path) -> Result, String> { let git_dir = repo_path.join(".git"); if !git_dir.is_dir() { diff --git a/codex-rs/core-plugins/src/startup_sync_tests.rs b/codex-rs/core-plugins/src/startup_sync_tests.rs index f73e4ad9d1..caab80705c 100644 --- a/codex-rs/core-plugins/src/startup_sync_tests.rs +++ b/codex-rs/core-plugins/src/startup_sync_tests.rs @@ -178,6 +178,7 @@ async fn run_sync_with_transport_overrides( &git_binary, &api_base_url, &backup_archive_api_url, + /*auth_route_config*/ None, ) }) .await @@ -190,7 +191,11 @@ async fn run_http_sync( ) -> Result { let api_base_url = api_base_url.into(); tokio::task::spawn_blocking(move || { - sync_openai_plugins_repo_via_http(codex_home.as_path(), &api_base_url) + sync_openai_plugins_repo_via_http( + codex_home.as_path(), + &api_base_url, + /*auth_route_config*/ None, + ) }) .await .expect("sync task should join") @@ -329,6 +334,7 @@ exit 1 git_path.to_str().expect("utf8 path"), "http://127.0.0.1:9", "http://127.0.0.1:9/backend-api/plugins/export/curated", + /*auth_route_config*/ None, ) }; let first = scope.spawn(run_sync); diff --git a/codex-rs/core-plugins/src/test_support.rs b/codex-rs/core-plugins/src/test_support.rs index 2da64f9674..397c5cd665 100644 --- a/codex-rs/core-plugins/src/test_support.rs +++ b/codex-rs/core-plugins/src/test_support.rs @@ -115,6 +115,7 @@ pub(crate) async fn load_plugins_config(codex_home: &Path, cwd: &Path) -> Plugin /*default_enabled*/ false, ), "https://chatgpt.com/backend-api/".to_string(), + /*auth_route_config*/ None, ) } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 75ed2039ac..1c77836470 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -63,10 +63,12 @@ use codex_api::create_text_param_for_request; use codex_api::response_create_client_metadata; use codex_app_server_protocol::AuthMode; use codex_login::AuthManager; +use codex_login::AuthRouteConfig; use codex_login::CodexAuth; use codex_login::RefreshTokenError; use codex_login::UnauthorizedRecovery; -use codex_login::default_client::build_reqwest_client; +use codex_login::default_client::ClientRouteClass; +use codex_login::default_client::build_default_reqwest_client_for_route; use codex_otel::SessionTelemetry; use codex_otel::current_span_w3c_trace_context; @@ -171,6 +173,7 @@ pub(crate) struct CompactConversationRequestSettings { struct ModelClientState { thread_id: ThreadId, provider: SharedModelProvider, + auth_route_config: Option, auth_env_telemetry: AuthEnvTelemetry, session_source: SessionSource, model_verbosity: Option, @@ -325,6 +328,10 @@ impl ModelClient { beta_features_header: Option, attestation_provider: Option>, ) -> Self { + let auth_route_config = auth_manager + .as_ref() + .and_then(|manager| manager.auth_route_config()) + .cloned(); let model_provider = create_model_provider(provider_info, auth_manager); let codex_api_key_env_enabled = model_provider .auth_manager() @@ -337,6 +344,7 @@ impl ModelClient { state: Arc::new(ModelClientState { thread_id, provider: model_provider, + auth_route_config, auth_env_telemetry, session_source, model_verbosity, @@ -441,7 +449,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_http_transport(&client_setup.api_provider, RESPONSES_COMPACT_ENDPOINT)?; let request_telemetry = Self::build_request_telemetry( session_telemetry, AuthRequestTelemetryContext::new( @@ -534,7 +543,7 @@ impl ModelClient { sideband_headers.extend(sideband_websocket_auth_headers( client_setup.api_auth.as_ref(), )); - let transport = ReqwestTransport::new(build_reqwest_client()); + let transport = self.build_http_transport(&client_setup.api_provider, "/realtime/calls")?; let response = ApiRealtimeCallClient::new(transport, client_setup.api_provider, client_setup.api_auth) .create_with_session_and_headers(sdp, session_config, extra_headers) @@ -565,7 +574,8 @@ impl ModelClient { } let client_setup = self.current_client_setup().await?; - let transport = ReqwestTransport::new(build_reqwest_client()); + let transport = + self.build_http_transport(&client_setup.api_provider, MEMORIES_SUMMARIZE_ENDPOINT)?; let request_telemetry = Self::build_request_telemetry( session_telemetry, AuthRequestTelemetryContext::new( @@ -765,6 +775,11 @@ impl ModelClient { pub fn responses_websocket_enabled(&self) -> bool { if !self.state.provider.info().supports_websockets || self.state.disable_websockets.load(Ordering::Relaxed) + || self + .state + .auth_route_config + .as_ref() + .is_some_and(|config| config.requires_route_aware_transport()) { return false; } @@ -772,6 +787,21 @@ impl ModelClient { true } + fn build_http_transport( + &self, + api_provider: &ApiProvider, + endpoint: &str, + ) -> Result { + let request_url = api_provider.url_for_path(endpoint); + let client = build_default_reqwest_client_for_route( + &request_url, + ClientRouteClass::Api, + self.state.auth_route_config.as_ref(), + ) + .map_err(std::io::Error::from)?; + Ok(ReqwestTransport::new(client)) + } + /// Returns auth + provider configuration resolved from the current session auth state. /// /// This centralizes setup used by both prewarm and normal request paths so they stay in @@ -1212,7 +1242,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_http_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(), diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 18cd202236..aafc2dc4ac 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -16,7 +16,9 @@ use crate::test_support::responses_metadata as test_responses_metadata; use codex_api::ApiError; use codex_api::ResponseEvent; use codex_app_server_protocol::AuthMode; +use codex_login::AuthCredentialsStoreMode; use codex_login::AuthManager; +use codex_login::AuthRouteConfig; use codex_login::CodexAuth; use codex_model_provider::BearerAuthProvider; use codex_model_provider_info::CHATGPT_CODEX_BASE_URL; @@ -80,6 +82,34 @@ fn test_model_client(session_source: SessionSource) -> ModelClient { ) } +#[tokio::test] +async fn route_aware_model_client_uses_http_instead_of_direct_websocket() { + let codex_home = TempDir::new().expect("temporary Codex home"); + let auth_manager = Arc::new( + AuthManager::new_with_auth_route_config( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + Some(AuthRouteConfig::system()), + ) + .await, + ); + let model_client = ModelClient::new( + Some(auth_manager), + ThreadId::new(), + ModelProviderInfo::create_openai_provider(/*base_url*/ None), + SessionSource::Exec, + /*model_verbosity*/ None, + /*enable_request_compression*/ false, + /*include_timing_metrics*/ false, + /*beta_features_header*/ None, + /*attestation_provider*/ None, + ); + + assert!(!model_client.responses_websocket_enabled()); +} + fn test_responses_metadata_for_client( client: &ModelClient, turn_id: Option<&str>, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index aa35848eab..959a497e4e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1397,6 +1397,9 @@ impl Config { self.features.enabled(Feature::Plugins), self.features.enabled(Feature::RemotePlugin), self.chatgpt_base_url.clone(), + self.system_proxy + .as_ref() + .map(auth_route_config_from_system_proxy_config), ) } diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index a6da04e77c..6d70d9d539 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -324,6 +324,7 @@ remote_plugin = true codex_home.path(), &RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), + auth_route_config: None, }, Some(&auth), ) diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index 365c722e28..2a7f688ff6 100644 --- a/codex-rs/login/src/auth/default_client.rs +++ b/codex-rs/login/src/auth/default_client.rs @@ -6,7 +6,7 @@ use codex_client::BuildCustomCaTransportError; use codex_client::BuildRouteAwareHttpClientError; -use codex_client::ClientRouteClass; +pub use codex_client::ClientRouteClass; use codex_client::CodexHttpClient; pub use codex_client::CodexRequestBuilder; use codex_client::build_reqwest_client_for_route; @@ -247,6 +247,15 @@ pub(crate) fn build_auth_reqwest_client_with_auth_route_config( pub(crate) fn build_default_reqwest_client_for_auth_route( endpoint: &str, auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + build_default_reqwest_client_for_route(endpoint, ClientRouteClass::Auth, auth_route_config) +} + +/// Builds the standard Codex reqwest client for a known destination. +pub fn build_default_reqwest_client_for_route( + endpoint: &str, + route_class: ClientRouteClass, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result { let Some(route_config) = auth_route_config.and_then(AuthRouteConfig::route_config) else { return Ok(build_reqwest_client()); @@ -256,17 +265,21 @@ pub(crate) fn build_default_reqwest_client_for_auth_route( if is_sandboxed() { return build_reqwest_client_with_custom_ca(builder).map_err(Into::into); } - build_reqwest_client_for_route( - builder, - endpoint, - ClientRouteClass::Auth, - Some(route_config), - ) + build_reqwest_client_for_route(builder, endpoint, route_class, Some(route_config)) } pub(crate) fn create_client_for_auth_route( endpoint: &str, auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + create_client_for_route(endpoint, ClientRouteClass::Auth, auth_route_config) +} + +/// Creates the standard traced Codex HTTP client for a known destination. +pub fn create_client_for_route( + endpoint: &str, + route_class: ClientRouteClass, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result { if auth_route_config .and_then(AuthRouteConfig::route_config) @@ -275,7 +288,7 @@ pub(crate) fn create_client_for_auth_route( return Ok(create_client()); } - build_default_reqwest_client_for_auth_route(endpoint, auth_route_config) + build_default_reqwest_client_for_route(endpoint, route_class, auth_route_config) .map(CodexHttpClient::new) } diff --git a/codex-rs/login/src/auth/default_client_tests.rs b/codex-rs/login/src/auth/default_client_tests.rs index ae673f5595..68ae99c398 100644 --- a/codex-rs/login/src/auth/default_client_tests.rs +++ b/codex-rs/login/src/auth/default_client_tests.rs @@ -117,12 +117,40 @@ fn auth_route_client_without_proxy_config_preserves_default_client_fallback() { ); assert!(raw_no_proxy_client.is_ok()); + let api_no_proxy_client = build_default_reqwest_client_for_route( + "https://api.openai.com/v1/models", + ClientRouteClass::Api, + /*auth_route_config*/ None, + ); + assert!(api_no_proxy_client.is_ok()); + + let traced_api_no_proxy_client = create_client_for_route( + "https://api.openai.com/v1/models", + ClientRouteClass::Api, + /*auth_route_config*/ None, + ); + assert!(traced_api_no_proxy_client.is_ok()); + let auth_route_config = AuthRouteConfig::direct(); let explicit_proxy_client = create_client_for_auth_route( "https://auth.openai.com/oauth/token", Some(&auth_route_config), ); assert!(explicit_proxy_client.is_err()); + + let explicit_api_client = build_default_reqwest_client_for_route( + "https://api.openai.com/v1/models", + ClientRouteClass::Api, + Some(&auth_route_config), + ); + assert!(explicit_api_client.is_err()); + + let traced_explicit_api_client = create_client_for_route( + "https://api.openai.com/v1/models", + ClientRouteClass::Api, + Some(&auth_route_config), + ); + assert!(traced_explicit_api_client.is_err()); } #[test] diff --git a/codex-rs/login/src/outbound_proxy.rs b/codex-rs/login/src/outbound_proxy.rs index 071b028000..e143519332 100644 --- a/codex-rs/login/src/outbound_proxy.rs +++ b/codex-rs/login/src/outbound_proxy.rs @@ -3,9 +3,9 @@ use codex_client::OutboundProxyMode; use codex_config::types::SystemProxyFeatureConfigToml; use codex_config::types::SystemProxyFeatureModeToml; -/// Stable route-selection policy for auth-owned clients. +/// Stable route-selection policy for Codex-owned clients. /// -/// Auth call sites should accept this type instead of lower-level resolver +/// Call sites should accept this type instead of lower-level resolver /// details. Config parsing decides whether to construct one, and the client /// layer remains responsible for platform-specific proxy resolution. #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -35,6 +35,13 @@ impl AuthRouteConfig { self.route_config.as_ref() } + /// Returns whether clients need a proxy-aware transport rather than a direct socket. + pub fn requires_route_aware_transport(&self) -> bool { + self.route_config + .as_ref() + .is_some_and(|config| config.mode() != OutboundProxyMode::Direct) + } + fn from_outbound_proxy_mode(mode: OutboundProxyMode) -> Self { Self { route_config: Some(OutboundProxyConfig::new(mode)), @@ -102,5 +109,11 @@ mod tests { Some(expected_config) ); } + + assert!(AuthRouteConfig::auto().requires_route_aware_transport()); + assert!(AuthRouteConfig::env().requires_route_aware_transport()); + assert!(AuthRouteConfig::system().requires_route_aware_transport()); + assert!(!AuthRouteConfig::direct().requires_route_aware_transport()); + assert!(!AuthRouteConfig::default().requires_route_aware_transport()); } } diff --git a/codex-rs/memories/write/src/guard.rs b/codex-rs/memories/write/src/guard.rs index 4d75043f97..e33e0b8c61 100644 --- a/codex-rs/memories/write/src/guard.rs +++ b/codex-rs/memories/write/src/guard.rs @@ -18,9 +18,13 @@ async fn rate_limits_check(auth_manager: &AuthManager, config: &Config) -> Optio return None; } - let client = BackendClient::from_auth(config.chatgpt_base_url.clone(), &auth) - .map_err(|err| warn!(%err, "failed to construct backend client")) - .ok()?; + let client = BackendClient::from_auth_with_route_config( + config.chatgpt_base_url.clone(), + &auth, + auth_manager.auth_route_config(), + ) + .map_err(|err| warn!(%err, "failed to construct backend client")) + .ok()?; let snapshots = client .get_rate_limits_many() diff --git a/codex-rs/model-provider/src/models_endpoint.rs b/codex-rs/model-provider/src/models_endpoint.rs index 8a72beea70..f7101ef5c4 100644 --- a/codex-rs/model-provider/src/models_endpoint.rs +++ b/codex-rs/model-provider/src/models_endpoint.rs @@ -12,9 +12,11 @@ use codex_feedback::FeedbackRequestTags; use codex_feedback::emit_feedback_request_tags_with_auth_env; use codex_login::AuthEnvTelemetry; use codex_login::AuthManager; +use codex_login::AuthRouteConfig; use codex_login::CodexAuth; use codex_login::collect_auth_env_telemetry; -use codex_login::default_client::build_reqwest_client; +use codex_login::default_client::ClientRouteClass; +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_otel::TelemetryAuthMode; @@ -36,16 +38,19 @@ const MODELS_ENDPOINT: &str = "/models"; pub(crate) struct OpenAiModelsEndpoint { provider_info: ModelProviderInfo, auth_manager: Option>, + auth_route_config: Option, } impl OpenAiModelsEndpoint { pub(crate) fn new( provider_info: ModelProviderInfo, auth_manager: Option>, + auth_route_config: Option, ) -> Self { Self { provider_info, auth_manager, + auth_route_config, } } @@ -88,7 +93,14 @@ impl ModelsEndpointClient for 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 http = build_default_reqwest_client_for_route( + &request_url, + ClientRouteClass::Api, + self.auth_route_config.as_ref(), + ) + .map_err(std::io::Error::from)?; + let transport = ReqwestTransport::new(http); let auth_telemetry = auth_header_telemetry(api_auth.as_ref()); let request_telemetry: Arc = Arc::new(ModelsRequestTelemetry { auth_mode: auth_mode.map(|mode| TelemetryAuthMode::from(mode).to_string()), @@ -230,6 +242,7 @@ mod tests { let endpoint = OpenAiModelsEndpoint::new( provider_info_with_command_auth(), /*auth_manager*/ None, + /*auth_route_config*/ None, ); assert!(endpoint.has_command_auth()); @@ -240,6 +253,7 @@ mod tests { let endpoint = OpenAiModelsEndpoint::new( ModelProviderInfo::create_openai_provider(/*base_url*/ None), /*auth_manager*/ None, + /*auth_route_config*/ None, ); assert!(!endpoint.has_command_auth()); diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 95427465eb..9100ef256a 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use codex_api::Provider; use codex_api::SharedAuthProvider; use codex_login::AuthManager; +use codex_login::AuthRouteConfig; use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::manager::OpenAiModelsManager; @@ -190,14 +191,20 @@ pub fn create_model_provider( struct ConfiguredModelProvider { info: ModelProviderInfo, auth_manager: Option>, + auth_route_config: Option, } impl ConfiguredModelProvider { fn new(provider_info: ModelProviderInfo, auth_manager: Option>) -> Self { + let auth_route_config = auth_manager + .as_ref() + .and_then(|manager| manager.auth_route_config()) + .cloned(); let auth_manager = auth_manager_for_provider(auth_manager, &provider_info); Self { info: provider_info, auth_manager, + auth_route_config, } } } @@ -282,6 +289,7 @@ impl ModelProvider for ConfiguredModelProvider { let endpoint = Arc::new(OpenAiModelsEndpoint::new( self.info.clone(), self.auth_manager.clone(), + self.auth_route_config.clone(), )); Arc::new(OpenAiModelsManager::new( codex_home,