From 5d3d9067af2ddbce6e86c9a1062cfb53b2b87459 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 13 May 2026 23:36:36 +0800 Subject: [PATCH] feat(proxy): forward client HTTP method instead of hard-coding POST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forwarder used to call client.post(&url) / http::Method::POST in both the reqwest and hyper paths, and the Gemini route table only registered POST /v1beta/*. As a result anything the Gemini SDK / CLI sent as GET (models list, models/ info) hit a 404 at the router and bypassed the local proxy's stats, rectifiers, and failover. Thread the request method end-to-end: - ProviderAdapter forwarder API now takes the http::Method by reference per attempt and dispatches client.request(method, &url) for reqwest and method.clone() for the hyper raw path. - All five callers in handlers.rs (handle_messages_for_app for Claude / Claude Desktop, handle_chat_completions, handle_responses, handle_responses_compact, handle_gemini) pull the method out of the incoming axum::extract::Request and pass it on. - handle_gemini tolerates an empty body (GET endpoints have none) and the forwarder skips serializing / sending a body for GET / HEAD — attaching JSON to a GET makes Gemini reject the request. - server.rs swaps the Gemini routes to any(handle_gemini) so the same handler handles GET / POST / PUT / DELETE, and adds /gemini/v1/* for the GA path version. --- src-tauri/src/proxy/forwarder.rs | 29 +++++++++++++++++++++++------ src-tauri/src/proxy/handlers.rs | 20 ++++++++++++++++++-- src-tauri/src/proxy/server.rs | 13 ++++++++++--- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 418147210..d66196486 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -160,9 +160,11 @@ impl RequestForwarder { /// `active_connections` / 刷新 `last_request_at`,无论 inner 走哪条出口路径, /// 出口处都会把 `active_connections` 回收。Per-attempt 维度(成功/失败/熔断 /// 等)仍由 inner 内自行更新 `success_requests` / `failed_requests`。 + #[allow(clippy::too_many_arguments)] pub async fn forward_with_retry( &self, app_type: &AppType, + method: http::Method, endpoint: &str, body: Value, headers: axum::http::HeaderMap, @@ -176,7 +178,9 @@ impl RequestForwarder { s.last_request_at = Some(chrono::Utc::now().to_rfc3339()); } let result = self - .forward_with_retry_inner(app_type, endpoint, body, headers, extensions, providers) + .forward_with_retry_inner( + app_type, method, endpoint, body, headers, extensions, providers, + ) .await; { let mut s = self.status.write().await; @@ -189,13 +193,16 @@ impl RequestForwarder { /// /// # Arguments /// * `app_type` - 应用类型 + /// * `method` - 客户端请求的 HTTP 方法(透传给上游,支持 GET/POST 等) /// * `endpoint` - API 端点 /// * `body` - 请求体 /// * `headers` - 请求头 /// * `providers` - 已选择的 Provider 列表(由 RequestContext 提供,避免重复调用 select_providers) + #[allow(clippy::too_many_arguments)] async fn forward_with_retry_inner( &self, app_type: &AppType, + method: http::Method, endpoint: &str, body: Value, headers: axum::http::HeaderMap, @@ -286,6 +293,7 @@ impl RequestForwarder { match self .forward( app_type, + &method, provider, endpoint, &provider_body, @@ -410,6 +418,7 @@ impl RequestForwarder { match self .forward( app_type, + &method, provider, endpoint, &provider_body, @@ -616,6 +625,7 @@ impl RequestForwarder { match self .forward( app_type, + &method, provider, endpoint, &provider_body, @@ -864,6 +874,7 @@ impl RequestForwarder { async fn forward( &self, app_type: &AppType, + method: &http::Method, provider: &Provider, endpoint: &str, body: &Value, @@ -1493,9 +1504,15 @@ impl RequestForwarder { ordered_headers.insert(name, value); } - // 序列化请求体 - let body_bytes = serde_json::to_vec(&filtered_body) - .map_err(|e| ProxyError::Internal(format!("Failed to serialize request body: {e}")))?; + // 序列化请求体。GET/HEAD 是 idempotent/safe 方法,按 HTTP 语义不应携带 body; + // 强行附带 JSON body 会让某些上游(如 Google Gemini 的 models.list)拒绝请求。 + let body_bytes = if matches!(method, &http::Method::GET | &http::Method::HEAD) { + Vec::new() + } else { + serde_json::to_vec(&filtered_body).map_err(|e| { + ProxyError::Internal(format!("Failed to serialize request body: {e}")) + })? + }; // 确保 content-type 存在 if !ordered_headers.contains_key(http::header::CONTENT_TYPE) { @@ -1553,7 +1570,7 @@ impl RequestForwarder { "[Forwarder] Using pooled reqwest client (preserve_exact_header_case={preserve_exact_header_case}, socks_proxy={is_socks_proxy})" ); let client = super::http_client::get(); - let mut request = client.post(&url); + let mut request = client.request(method.clone(), &url); let request_is_streaming = is_streaming_request(&effective_endpoint, &filtered_body, headers); if request_is_streaming { @@ -1594,7 +1611,7 @@ impl RequestForwarder { .map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?; super::hyper_client::send_request( uri, - http::Method::POST, + method.clone(), ordered_headers, extensions.clone(), body_bytes, diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 58ee274cc..71a95e946 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -115,6 +115,7 @@ async fn handle_messages_for_app( strip_prefix: Option<&'static str>, ) -> Result { let (parts, body) = request.into_parts(); + let method = parts.method.clone(); let uri = parts.uri; let headers = parts.headers; let extensions = parts.extensions; @@ -147,6 +148,7 @@ async fn handle_messages_for_app( let result = match forwarder .forward_with_retry( &app_type, + method, endpoint, body.clone(), headers, @@ -453,6 +455,7 @@ pub async fn handle_chat_completions( request: axum::extract::Request, ) -> Result { let (parts, req_body) = request.into_parts(); + let method = parts.method.clone(); let uri = parts.uri; let headers = parts.headers; let extensions = parts.extensions; @@ -477,6 +480,7 @@ pub async fn handle_chat_completions( let result = match forwarder .forward_with_retry( &AppType::Codex, + method, &endpoint, body, headers, @@ -507,6 +511,7 @@ pub async fn handle_responses( request: axum::extract::Request, ) -> Result { let (parts, req_body) = request.into_parts(); + let method = parts.method.clone(); let uri = parts.uri; let headers = parts.headers; let extensions = parts.extensions; @@ -531,6 +536,7 @@ pub async fn handle_responses( let result = match forwarder .forward_with_retry( &AppType::Codex, + method, &endpoint, body, headers, @@ -561,6 +567,7 @@ pub async fn handle_responses_compact( request: axum::extract::Request, ) -> Result { let (parts, req_body) = request.into_parts(); + let method = parts.method.clone(); let uri = parts.uri; let headers = parts.headers; let extensions = parts.extensions; @@ -585,6 +592,7 @@ pub async fn handle_responses_compact( let result = match forwarder .forward_with_retry( &AppType::Codex, + method, &endpoint, body, headers, @@ -620,6 +628,7 @@ pub async fn handle_gemini( request: axum::extract::Request, ) -> Result { let (parts, req_body) = request.into_parts(); + let method = parts.method.clone(); let headers = parts.headers; let extensions = parts.extensions; let body_bytes = req_body @@ -627,8 +636,14 @@ pub async fn handle_gemini( .await .map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))? .to_bytes(); - let body: Value = serde_json::from_slice(&body_bytes) - .map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?; + // GET 类只读端点(/v1beta/models、/v1beta/models/ 等)没有请求体, + // 不能强制 parse 为 JSON —— 否则空 body 会被拒绝。 + let body: Value = if body_bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&body_bytes) + .map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))? + }; // Gemini 的模型名称在 URI 中 let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini") @@ -650,6 +665,7 @@ pub async fn handle_gemini( let result = match forwarder .forward_with_retry( &AppType::Gemini, + method, endpoint, body, headers, diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 475069a98..b0595b83e 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -16,7 +16,7 @@ use super::{ use crate::database::Database; use axum::{ extract::DefaultBodyLimit, - routing::{get, post}, + routing::{any, get, post}, Router, }; use hyper_util::rt::TokioIo; @@ -331,8 +331,15 @@ impl ProxyServer { post(handlers::handle_responses_compact), ) // Gemini API (支持带前缀和不带前缀) - .route("/v1beta/*path", post(handlers::handle_gemini)) - .route("/gemini/v1beta/*path", post(handlers::handle_gemini)) + // + // 用 `any(..)` 覆盖所有 HTTP 方法:除了 POST `:generateContent` / + // `:streamGenerateContent` / `:countTokens` 之外,Gemini SDK / CLI 还会发 + // GET `/models`、GET `/models/` 等只读端点。如果只挂 POST,这些 GET + // 请求会在路由层 404,绕过本地代理的统计、整流和故障转移。 + .route("/v1beta/*path", any(handlers::handle_gemini)) + .route("/gemini/v1beta/*path", any(handlers::handle_gemini)) + // Gemini 的 GA 版本也叫 /v1,给原 SDK 留一条出口 + .route("/gemini/v1/*path", any(handlers::handle_gemini)) // 提高默认请求体大小限制(避免 413 Payload Too Large) .layer(DefaultBodyLimit::max(200 * 1024 * 1024)) .with_state(self.state.clone())