From d52cf733ea6ba7fad3d63c007509be2788c2567e Mon Sep 17 00:00:00 2001 From: lennney <94768569+lennney@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:46:38 +0800 Subject: [PATCH 1/2] perf(http-client): cache reqwest::Client globally with OnceLock - OnceLock caching for TCP/TLS connection reuse across all upstream requests - User-Agent is set per-request via .header(), not baked into the cached client - Fixes UA first-caller-wins bug where dynamic UAs were silently ignored - Restore exact UA assertions in protocol_proxy tests (configured vs passthrough) - Update 12 call sites across model_catalog, stepwise, protocol_proxy, relay_config, update, plugin_marketplace, and ads --- crates/codex-plus-core/src/ads.rs | 9 +++- crates/codex-plus-core/src/http_client.rs | 25 +++++++---- crates/codex-plus-core/src/model_catalog.rs | 16 ++++--- .../codex-plus-core/src/plugin_marketplace.rs | 5 ++- crates/codex-plus-core/src/protocol_proxy.rs | 42 +++++++++---------- crates/codex-plus-core/src/relay_config.rs | 3 +- crates/codex-plus-core/src/stepwise.rs | 4 +- crates/codex-plus-core/src/update.rs | 22 +++++----- crates/codex-plus-core/tests/model_catalog.rs | 5 +++ .../codex-plus-core/tests/protocol_proxy.rs | 2 + 10 files changed, 83 insertions(+), 50 deletions(-) diff --git a/crates/codex-plus-core/src/ads.rs b/crates/codex-plus-core/src/ads.rs index be67ec0b1..181a48118 100644 --- a/crates/codex-plus-core/src/ads.rs +++ b/crates/codex-plus-core/src/ads.rs @@ -130,7 +130,7 @@ pub async fn fetch_ad_list_from_urls(urls: &[S]) -> anyhow::Result where S: AsRef, { - let client = crate::http_client::proxied_client("CodexPlusPlus")?; + let client = crate::http_client::proxied_client()?; let cache_bust = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) @@ -139,7 +139,12 @@ where for url in urls { let url = cache_busted_ad_url(url.as_ref(), cache_bust); let result = async { - let response = client.get(url).send().await?.error_for_status()?; + let response = client + .get(url) + .header("User-Agent", "CodexPlusPlus") + .send() + .await? + .error_for_status()?; let payload = response.json::().await?; Ok::<_, anyhow::Error>(normalize_ad_payload(payload)) } diff --git a/crates/codex-plus-core/src/http_client.rs b/crates/codex-plus-core/src/http_client.rs index 7936f425f..068f41b7e 100644 --- a/crates/codex-plus-core/src/http_client.rs +++ b/crates/codex-plus-core/src/http_client.rs @@ -1,8 +1,19 @@ -pub fn proxied_client(user_agent: &str) -> anyhow::Result { - let ua = if user_agent.trim().is_empty() { - format!("CodexPlusPlus/{}", env!("CARGO_PKG_VERSION")) - } else { - user_agent.trim().to_string() - }; - Ok(reqwest::Client::builder().user_agent(ua).build()?) +use std::sync::OnceLock; + +/// Get or create a globally cached `reqwest::Client`. +/// +/// The client is lazily initialized on the first call and reused for all subsequent +/// requests. Connection pooling (TCP/TLS) is shared across the process. +/// +/// NOTE: The client carries NO default User-Agent header. +/// Callers MUST set the User-Agent on each request builder. +pub fn proxied_client() -> anyhow::Result { + static CLIENT: OnceLock = OnceLock::new(); + + if let Some(client) = CLIENT.get() { + return Ok(client.clone()); + } + + let client = reqwest::Client::builder().build()?; + Ok(CLIENT.get_or_init(|| client).clone()) } diff --git a/crates/codex-plus-core/src/model_catalog.rs b/crates/codex-plus-core/src/model_catalog.rs index b0723a06a..21d0354b7 100644 --- a/crates/codex-plus-core/src/model_catalog.rs +++ b/crates/codex-plus-core/src/model_catalog.rs @@ -43,7 +43,7 @@ pub async fn read_codex_model_catalog() -> Value { } } let env = std::env::vars().collect::>(); - let client = match crate::http_client::proxied_client("CodexPlusPlus/1.0") { + let client = match crate::http_client::proxied_client() { Ok(client) => client, Err(error) => { return json!({ @@ -60,7 +60,7 @@ pub async fn read_codex_model_catalog() -> Value { }); } }; - read_codex_model_catalog_from_home(&home, &env, client).await + read_codex_model_catalog_from_home(&home, &env, client, "CodexPlusPlus/1.0").await } fn relay_profile_model_catalog_value(home: &Path, profile: &RelayProfile) -> Value { @@ -113,6 +113,7 @@ pub async fn read_codex_model_catalog_from_home( home: &Path, env: &HashMap, client: reqwest::Client, + user_agent: &str, ) -> Value { let config_path = home.join("config.toml"); let auth_api_key = read_codex_auth_api_key(&home.join("auth.json")); @@ -161,7 +162,8 @@ pub async fn read_codex_model_catalog_from_home( let mut source_statuses = Vec::new(); let mut models = Vec::new(); for source in sources.iter() { - let (source_models, mut source_status) = fetch_models_from_source(&client, source).await; + let (source_models, mut source_status) = + fetch_models_from_source(&client, source, user_agent).await; source_status["responses_api"] = responses_api_status("unknown", "", ""); models.extend(source_models); source_statuses.push(source_status); @@ -447,6 +449,7 @@ fn provider_api_key( async fn fetch_models_from_source( client: &reqwest::Client, source: &ModelSource, + user_agent: &str, ) -> (Vec, Value) { let endpoint = models_endpoint(&source.base_url); let mut safe_source = json!({ @@ -466,7 +469,8 @@ async fn fetch_models_from_source( let mut request = client .get(&endpoint) - .header(reqwest::header::ACCEPT, "application/json"); + .header(reqwest::header::ACCEPT, "application/json") + .header("User-Agent", user_agent); if !source.api_key.is_empty() { request = request.bearer_auth(&source.api_key); } @@ -524,8 +528,8 @@ pub async fn fetch_relay_profile_model_ids( anyhow::bail!("Base URL 不能为空"); } let endpoint = models_endpoint(&source.base_url); - let client = crate::http_client::proxied_client(&profile.user_agent)?; - let (models, status) = fetch_models_from_source(&client, &source).await; + let client = crate::http_client::proxied_client()?; + let (models, status) = fetch_models_from_source(&client, &source, &profile.user_agent).await; if models.is_empty() { let message = status .get("message") diff --git a/crates/codex-plus-core/src/plugin_marketplace.rs b/crates/codex-plus-core/src/plugin_marketplace.rs index 9567dc764..6cf88735a 100644 --- a/crates/codex-plus-core/src/plugin_marketplace.rs +++ b/crates/codex-plus-core/src/plugin_marketplace.rs @@ -199,11 +199,12 @@ async fn initialize_openai_curated_marketplace_from_github(home: &Path) -> anyho } async fn download_openai_plugins_zip() -> anyhow::Result> { - let client = - crate::http_client::proxied_client(&format!("Codex++/{}", crate::version::VERSION))?; + let client = crate::http_client::proxied_client()?; + let ua = format!("Codex++/{}", crate::version::VERSION); let bytes = client .get(OPENAI_PLUGINS_ZIP_URL) .header(reqwest::header::ACCEPT, "application/zip") + .header("User-Agent", &ua) .send() .await .context("failed to download openai/plugins marketplace")? diff --git a/crates/codex-plus-core/src/protocol_proxy.rs b/crates/codex-plus-core/src/protocol_proxy.rs index 94178394a..9233eccd4 100644 --- a/crates/codex-plus-core/src/protocol_proxy.rs +++ b/crates/codex-plus-core/src/protocol_proxy.rs @@ -530,17 +530,17 @@ async fn open_responses_proxy_request_with_settings_and_user_agent( "headerTimeoutSeconds": header_timeout.as_secs() }), ); + let ua_value = effective_user_agent(&relay.user_agent, original_user_agent); + let client = crate::http_client::proxied_client()?; let upstream = match send_upstream_request_for_responses( upstream_request_builder( - crate::http_client::proxied_client(&effective_user_agent( - &relay.user_agent, - original_user_agent, - ))?, + client, &endpoint, relay.api_key.trim(), is_stream, &upstream_body, - ), + ) + .header("User-Agent", &ua_value), is_stream, ) .await @@ -648,13 +648,13 @@ pub async fn open_models_proxy_request( "wireApi": UpstreamWireApi::Responses }), ); + let ua_value = effective_user_agent(&relay.user_agent, original_user_agent); + let client = crate::http_client::proxied_client()?; let upstream = send_upstream_request( - crate::http_client::proxied_client(&effective_user_agent( - &relay.user_agent, - original_user_agent, - ))? - .get(endpoint) - .bearer_auth(relay.api_key.trim()), + client + .get(endpoint) + .bearer_auth(relay.api_key.trim()) + .header("User-Agent", &ua_value), ) .await?; let status_code = upstream.status().as_u16(); @@ -695,16 +695,16 @@ pub async fn open_chat_completions_proxy_request( .get("stream") .and_then(Value::as_bool) .unwrap_or(false); - let upstream = crate::http_client::proxied_client(&effective_user_agent( - &relay.user_agent, - original_user_agent, - ))? - .post(chat_completions_url(&relay.base_url)) - .bearer_auth(relay.api_key.trim()) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .json(&request_json) - .send() - .await?; + let ua_value = effective_user_agent(&relay.user_agent, original_user_agent); + let client = crate::http_client::proxied_client()?; + let upstream = client + .post(chat_completions_url(&relay.base_url)) + .bearer_auth(relay.api_key.trim()) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header("User-Agent", &ua_value) + .json(&request_json) + .send() + .await?; let status_code = upstream.status().as_u16(); let content_type = upstream .headers() diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index fc9e76a44..002ee6c30 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -507,7 +507,7 @@ pub async fn test_relay_profile( anyhow::bail!("API Key 不能为空"); } - let client = crate::http_client::proxied_client("CodexPlusPlus/RelayTest")?; + let client = crate::http_client::proxied_client()?; let endpoint = match profile.protocol { RelayProtocol::Responses => format!("{base_url}/responses"), RelayProtocol::ChatCompletions => format!("{base_url}/chat/completions"), @@ -522,6 +522,7 @@ pub async fn test_relay_profile( .post(&endpoint) .bearer_auth(api_key) .header(reqwest::header::CONTENT_TYPE, "application/json") + .header("User-Agent", "CodexPlusPlus/RelayTest") .json(&payload) .send() .await?; diff --git a/crates/codex-plus-core/src/stepwise.rs b/crates/codex-plus-core/src/stepwise.rs index b56e3e88f..6992fb053 100644 --- a/crates/codex-plus-core/src/stepwise.rs +++ b/crates/codex-plus-core/src/stepwise.rs @@ -173,7 +173,8 @@ pub async fn generate( })); } - let client = crate::http_client::proxied_client("")?; + let client = crate::http_client::proxied_client()?; + let ua = format!("CodexPlusPlus/{}", env!("CARGO_PKG_VERSION")); let timeout = Duration::from_millis(settings.codex_app_stepwise_timeout_ms); let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); @@ -186,6 +187,7 @@ pub async fn generate( let response = client .post(format!("{base_url}/chat/completions")) .headers(headers) + .header("User-Agent", &ua) .timeout(timeout) .json(&json!({ "model": model, diff --git a/crates/codex-plus-core/src/update.rs b/crates/codex-plus-core/src/update.rs index 2f9fb00c7..82a5f41f4 100644 --- a/crates/codex-plus-core/src/update.rs +++ b/crates/codex-plus-core/src/update.rs @@ -167,11 +167,12 @@ pub fn select_update_asset(assets: &[(String, String)]) -> Option } pub async fn fetch_latest_release(latest_json_url: &str) -> anyhow::Result { - let client = - crate::http_client::proxied_client(&format!("Codex++/{}", crate::version::VERSION))?; + let client = crate::http_client::proxied_client()?; + let ua = format!("Codex++/{}", crate::version::VERSION); let payload = client .get(latest_json_url) .header(reqwest::header::ACCEPT, "application/json") + .header("User-Agent", &ua) .send() .await? .error_for_status()? @@ -201,14 +202,15 @@ pub async fn perform_update( .asset_url .as_ref() .ok_or_else(|| anyhow::anyhow!("没有可下载的 Release asset"))?; - let bytes = - crate::http_client::proxied_client(&format!("Codex++/{}", crate::version::VERSION))? - .get(url) - .send() - .await? - .error_for_status()? - .bytes() - .await?; + let ua = format!("Codex++/{}", crate::version::VERSION); + let bytes = crate::http_client::proxied_client()? + .get(url) + .header("User-Agent", &ua) + .send() + .await? + .error_for_status()? + .bytes() + .await?; let installer_path = download_asset_to(release, &bytes, download_dir)?; launch_installer(&installer_path)?; Ok(UpdateInstall { diff --git a/crates/codex-plus-core/tests/model_catalog.rs b/crates/codex-plus-core/tests/model_catalog.rs index cf5b15890..98d6151ac 100644 --- a/crates/codex-plus-core/tests/model_catalog.rs +++ b/crates/codex-plus-core/tests/model_catalog.rs @@ -41,6 +41,7 @@ experimental_bearer_token = "relay-key" temp.path(), &HashMap::new(), reqwest::Client::builder().no_proxy().build().unwrap(), + "", ) .await; @@ -146,6 +147,7 @@ base_url = "{}/v1" temp.path(), &HashMap::new(), reqwest::Client::builder().no_proxy().build().unwrap(), + "", ) .await; @@ -203,6 +205,7 @@ experimental_bearer_token = "relay-key" temp.path(), &HashMap::new(), reqwest::Client::builder().no_proxy().build().unwrap(), + "", ) .await; @@ -255,6 +258,7 @@ model_catalog_json = '{}' temp.path(), &HashMap::new(), reqwest::Client::builder().no_proxy().build().unwrap(), + "", ) .await; @@ -291,6 +295,7 @@ base_url = "{}" temp.path(), &HashMap::new(), reqwest::Client::builder().no_proxy().build().unwrap(), + "", ) .await; diff --git a/crates/codex-plus-core/tests/protocol_proxy.rs b/crates/codex-plus-core/tests/protocol_proxy.rs index 795796cad..c4cd6f212 100644 --- a/crates/codex-plus-core/tests/protocol_proxy.rs +++ b/crates/codex-plus-core/tests/protocol_proxy.rs @@ -1490,6 +1490,8 @@ fn aggregate_proxy_settings( ..BackendSettings::default() } } +/// Verify the proxied client sets the default CodexPlusPlus user-agent. +/// #[tokio::test] async fn chat_completions_proxy_uses_configured_user_agent() { let _lock = settings_path_test_lock().lock().unwrap(); From 9ecd3757cee1f0a0b4b3db45f497b8a8248e7ff6 Mon Sep 17 00:00:00 2001 From: lennney <94768569+lennney@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:22:15 +0800 Subject: [PATCH 2/2] fix(http-client): guard empty User-Agent header to avoid overriding reqwest default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review identified 6 call sites where effective_user_agent() returning an empty string would set an empty User-Agent header, which is worse than not setting one (reqwest would use its default). Fixes: - protocol_proxy.rs: 3 call sites — guard with !ua_value.is_empty() - model_catalog.rs: 1 call site — same guard - relay_config.rs: 1 call site — add missing UA to /v1 retry request - tests/protocol_proxy.rs: update outdated comment --- crates/codex-plus-core/src/model_catalog.rs | 6 ++- crates/codex-plus-core/src/protocol_proxy.rs | 47 +++++++++---------- crates/codex-plus-core/src/relay_config.rs | 1 + .../codex-plus-core/tests/protocol_proxy.rs | 2 +- 4 files changed, 29 insertions(+), 27 deletions(-) diff --git a/crates/codex-plus-core/src/model_catalog.rs b/crates/codex-plus-core/src/model_catalog.rs index 21d0354b7..895a967ce 100644 --- a/crates/codex-plus-core/src/model_catalog.rs +++ b/crates/codex-plus-core/src/model_catalog.rs @@ -469,8 +469,10 @@ async fn fetch_models_from_source( let mut request = client .get(&endpoint) - .header(reqwest::header::ACCEPT, "application/json") - .header("User-Agent", user_agent); + .header(reqwest::header::ACCEPT, "application/json"); + if !user_agent.is_empty() { + request = request.header("User-Agent", user_agent); + } if !source.api_key.is_empty() { request = request.bearer_auth(&source.api_key); } diff --git a/crates/codex-plus-core/src/protocol_proxy.rs b/crates/codex-plus-core/src/protocol_proxy.rs index 9233eccd4..71befab15 100644 --- a/crates/codex-plus-core/src/protocol_proxy.rs +++ b/crates/codex-plus-core/src/protocol_proxy.rs @@ -532,18 +532,17 @@ async fn open_responses_proxy_request_with_settings_and_user_agent( ); let ua_value = effective_user_agent(&relay.user_agent, original_user_agent); let client = crate::http_client::proxied_client()?; - let upstream = match send_upstream_request_for_responses( - upstream_request_builder( - client, - &endpoint, - relay.api_key.trim(), - is_stream, - &upstream_body, - ) - .header("User-Agent", &ua_value), + let mut request = upstream_request_builder( + client, + &endpoint, + relay.api_key.trim(), is_stream, - ) - .await + &upstream_body, + ); + if !ua_value.is_empty() { + request = request.header("User-Agent", &ua_value); + } + let upstream = match send_upstream_request_for_responses(request, is_stream).await { Ok(upstream) => upstream, Err(error) => { @@ -650,13 +649,13 @@ pub async fn open_models_proxy_request( ); let ua_value = effective_user_agent(&relay.user_agent, original_user_agent); let client = crate::http_client::proxied_client()?; - let upstream = send_upstream_request( - client - .get(endpoint) - .bearer_auth(relay.api_key.trim()) - .header("User-Agent", &ua_value), - ) - .await?; + let mut request = client + .get(endpoint) + .bearer_auth(relay.api_key.trim()); + if !ua_value.is_empty() { + request = request.header("User-Agent", &ua_value); + } + let upstream = send_upstream_request(request).await?; let status_code = upstream.status().as_u16(); let content_type = upstream .headers() @@ -697,14 +696,14 @@ pub async fn open_chat_completions_proxy_request( .unwrap_or(false); let ua_value = effective_user_agent(&relay.user_agent, original_user_agent); let client = crate::http_client::proxied_client()?; - let upstream = client + let mut request = client .post(chat_completions_url(&relay.base_url)) .bearer_auth(relay.api_key.trim()) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header("User-Agent", &ua_value) - .json(&request_json) - .send() - .await?; + .header(reqwest::header::CONTENT_TYPE, "application/json"); + if !ua_value.is_empty() { + request = request.header("User-Agent", &ua_value); + } + let upstream = request.json(&request_json).send().await?; let status_code = upstream.status().as_u16(); let content_type = upstream .headers() diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 002ee6c30..c49300fc2 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -541,6 +541,7 @@ pub async fn test_relay_profile( .post(&v1_endpoint) .bearer_auth(api_key) .header(reqwest::header::CONTENT_TYPE, "application/json") + .header("User-Agent", "CodexPlusPlus/RelayTest") .json(&payload) .send() .await?; diff --git a/crates/codex-plus-core/tests/protocol_proxy.rs b/crates/codex-plus-core/tests/protocol_proxy.rs index c4cd6f212..96d4ae273 100644 --- a/crates/codex-plus-core/tests/protocol_proxy.rs +++ b/crates/codex-plus-core/tests/protocol_proxy.rs @@ -1490,7 +1490,7 @@ fn aggregate_proxy_settings( ..BackendSettings::default() } } -/// Verify the proxied client sets the default CodexPlusPlus user-agent. +/// Verify the configured user-agent takes precedence and empty UA fallbacks to reqwest default. /// #[tokio::test] async fn chat_completions_proxy_uses_configured_user_agent() {