From d046280b172a7a609549e6dec65d7d5d891f13ab Mon Sep 17 00:00:00 2001 From: efecanceliksoy Date: Wed, 6 May 2026 09:38:44 +0300 Subject: [PATCH 1/3] fix: force HTTP/1.1 for LLM API requests reqwest with rustls-tls negotiates HTTP/2 via ALPN, but some API servers (notably DeepSeek behind CloudFront) have body-framing issues with HTTP/2 on long-running responses. This causes reqwest to fail with "error decoding response body" when the response takes >30s (common for reasoning models like deepseek-v4-pro). Forcing HTTP/1.1 avoids this class of errors entirely without any performance penalty for single-request LLM calls. --- src/utils.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils.rs b/src/utils.rs index 182a511..2d38383 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -330,6 +330,7 @@ pub struct LLMClient { pub fn build_reqwest_client() -> reqwest::Client { reqwest::Client::builder() .no_proxy() + .http1_only() .build() .expect("failed to build reqwest client") } From 096f4426a823bc4b5f7ff3ee1e25106acbaddf81 Mon Sep 17 00:00:00 2001 From: efecnc Date: Wed, 6 May 2026 09:42:32 +0300 Subject: [PATCH 2/3] Update src/utils.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/utils.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils.rs b/src/utils.rs index 2d38383..353f80e 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -330,6 +330,8 @@ pub struct LLMClient { pub fn build_reqwest_client() -> reqwest::Client { reqwest::Client::builder() .no_proxy() + // Force HTTP/1.1 to avoid body-framing issues with some providers (e.g. DeepSeek/CloudFront) + // on long-running reasoning responses. .http1_only() .build() .expect("failed to build reqwest client") From 1849e8e189eb7de5686288c52fd2388433771a97 Mon Sep 17 00:00:00 2001 From: efecanceliksoy Date: Wed, 6 May 2026 12:38:58 +0300 Subject: [PATCH 3/3] fix: apply HTTP/1.1 only to DeepSeek, not all providers The original fix forced HTTP/1.1 for ALL providers via .http1_only() on the shared build_reqwest_client(), affecting OpenAI, Gemini, Anthropic, and OpenRouter when only DeepSeek behind CloudFront has the HTTP/2 body-framing issue. Changes: - Revert .http1_only() from build_reqwest_client() (no longer global) - Add build_reqwest_client_http1() for providers that need HTTP/1.1 - LLMClient::new_openai_compatible() stays HTTP/2-capable (default) - LLMClient::new_openai_compatible_http1() forces HTTP/1.1 - create_provider() checks provider_name for 'deepseek' and routes to the HTTP/1.1 client only for DeepSeek providers --- src/provider.rs | 12 ++++++++++-- src/utils.rs | 42 ++++++++++++++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/provider.rs b/src/provider.rs index 1e44c9b..533307a 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -8,6 +8,9 @@ use crate::utils::{ /// /// Routes `"anthropic"` to [`AnthropicProvider`] (Messages API) and everything else to /// [`OpenAIProvider`] (OpenAI-compatible chat completions). Temperature is set to 0.3. +/// +/// DeepSeek providers get an HTTP/1.1-only reqwest client to avoid HTTP/2 body-framing +/// issues with CloudFront on long-running reasoning responses. pub fn create_provider( provider_name: &str, base_url: &str, @@ -17,8 +20,13 @@ pub fn create_provider( if provider_name == "anthropic" { Box::new(AnthropicProvider::new(base_url, api_key, model_name).with_temperature(0.3)) } else { - let client = - LLMClient::new_openai_compatible(base_url, api_key, model_name).with_temperature(0.3); + let is_deepseek = provider_name.to_lowercase().contains("deepseek"); + let client = if is_deepseek { + LLMClient::new_openai_compatible_http1(base_url, api_key, model_name) + } else { + LLMClient::new_openai_compatible(base_url, api_key, model_name) + } + .with_temperature(0.3); Box::new(OpenAIProvider::new(client)) } } diff --git a/src/utils.rs b/src/utils.rs index 353f80e..fbbe7dd 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -330,8 +330,18 @@ pub struct LLMClient { pub fn build_reqwest_client() -> reqwest::Client { reqwest::Client::builder() .no_proxy() - // Force HTTP/1.1 to avoid body-framing issues with some providers (e.g. DeepSeek/CloudFront) - // on long-running reasoning responses. + .build() + .expect("failed to build reqwest client") +} + +/// Build a reqwest client forced to HTTP/1.1. +/// +/// Some providers (notably DeepSeek behind CloudFront) have HTTP/2 body-framing +/// issues on long-running responses. Use this builder only for providers known +/// to require HTTP/1.1. +pub fn build_reqwest_client_http1() -> reqwest::Client { + reqwest::Client::builder() + .no_proxy() .http1_only() .build() .expect("failed to build reqwest client") @@ -340,20 +350,32 @@ pub fn build_reqwest_client() -> reqwest::Client { impl LLMClient { /// Create a new client with default settings (temp=0.7, timeout=600s). pub fn new_openai_compatible(base_url: &str, api_key: &str, model: &str) -> Self { - // Ensure base_url ends with slash if needed, or handle path joining correctly - // For simplicity, we assume user gives enough of the path or we append standardized paths. - // However, OpenAI-compatible APIs often vary in suffix. - // We'll treat `base_url` as the full endpoint URL for completions for maximum flexibility, - // OR we can default to adding "/chat/completions" if the user provided base domain. - // Let's go with robust: User provides full endpoint or we construct. - // Actually, simplest usage: base_url is the helper. + Self::new_openai_compatible_with_http1(base_url, api_key, model, false) + } + + /// Create a new client with HTTP/1.1 forced (for providers with HTTP/2 framing issues). + pub fn new_openai_compatible_http1(base_url: &str, api_key: &str, model: &str) -> Self { + Self::new_openai_compatible_with_http1(base_url, api_key, model, true) + } + + fn new_openai_compatible_with_http1( + base_url: &str, + api_key: &str, + model: &str, + http1_only: bool, + ) -> Self { + let client = if http1_only { + build_reqwest_client_http1() + } else { + build_reqwest_client() + }; Self { base_url: base_url.to_string(), api_key: api_key.to_string(), model: model.to_string(), temperature: 0.7, timeout: Duration::from_secs(600), - client: build_reqwest_client(), + client, } }