feat(proxy): forward client HTTP method instead of hard-coding POST

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/<id> 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.
This commit is contained in:
Jason
2026-05-13 23:36:36 +08:00
parent f2ae9823cb
commit 5d3d9067af
3 changed files with 51 additions and 11 deletions
+23 -6
View File
@@ -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,
+18 -2
View File
@@ -115,6 +115,7 @@ async fn handle_messages_for_app(
strip_prefix: Option<&'static str>,
) -> Result<axum::response::Response, ProxyError> {
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<axum::response::Response, ProxyError> {
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<axum::response::Response, ProxyError> {
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<axum::response::Response, ProxyError> {
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<axum::response::Response, ProxyError> {
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/<model> 等)没有请求体,
// 不能强制 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,
+10 -3
View File
@@ -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/<id>` 等只读端点。如果只挂 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())