fix(proxy): passthrough client query params instead of forcing beta=true

This commit is contained in:
YoVinchen
2026-02-12 14:14:50 +08:00
parent b3188c242a
commit f0e09da83b
5 changed files with 34 additions and 49 deletions
+9 -7
View File
@@ -561,15 +561,17 @@ impl RequestForwarder {
// 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider);
let effective_endpoint =
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
"/v1/chat/completions"
} else {
endpoint
};
let effective_endpoint = if needs_transform
&& adapter.name() == "Claude"
&& endpoint.starts_with("/v1/messages")
{
endpoint.replacen("/v1/messages", "/v1/chat/completions", 1)
} else {
endpoint.to_string()
};
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, effective_endpoint);
let url = adapter.build_url(&base_url, &effective_endpoint);
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, _mapped_model) =
+8 -1
View File
@@ -56,6 +56,7 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
pub async fn handle_messages(
State(state): State<ProxyState>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
@@ -67,12 +68,18 @@ pub async fn handle_messages(
.and_then(|s| s.as_bool())
.unwrap_or(false);
// 透传客户端 query 参数(例如 ?beta=true),路径统一为 /v1/messages
let endpoint = uri
.query()
.map(|q| format!("/v1/messages?{q}"))
.unwrap_or_else(|| "/v1/messages".to_string());
// 转发请求
let forwarder = ctx.create_forwarder(&state);
let result = match forwarder
.forward_with_retry(
&AppType::Claude,
"/v1/messages",
&endpoint,
body.clone(),
headers,
ctx.get_providers(),
+7 -22
View File
@@ -280,17 +280,7 @@ impl ProviderAdapter for ClaudeAdapter {
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
let base = dedup_v1_v1_boundary_safe(base);
// 为 Claude Messages 端点添加 ?beta=true 参数
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
// 仅对 /v1/messages 生效,不对 /v1/chat/completions 生效
let needs_beta =
base.contains("/v1/messages") && !base.contains('?') && !suffix.starts_with('?');
if needs_beta {
format!("{base}?beta=true{suffix}")
} else {
format!("{base}{suffix}")
}
format!("{base}{suffix}")
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
@@ -502,23 +492,21 @@ mod tests {
#[test]
fn test_build_url_anthropic() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
assert_eq!(url, "https://api.anthropic.com/v1/messages");
}
#[test]
fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
}
#[test]
fn test_build_url_no_beta_for_other_endpoints() {
let adapter = ClaudeAdapter::new();
// 非 /v1/messages 端点不添加 ?beta=true
// 非 /v1/messages 端点保持原样
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
assert_eq!(url, "https://api.anthropic.com/v1/complete");
}
@@ -536,7 +524,7 @@ mod tests {
let adapter = ClaudeAdapter::new();
// base_url 已包含完整路径 /v1/messages,不再追加
let url = adapter.build_url("https://example.com/api/v1/messages", "/v1/messages");
assert_eq!(url, "https://example.com/api/v1/messages?beta=true");
assert_eq!(url, "https://example.com/api/v1/messages");
}
#[test]
@@ -574,14 +562,11 @@ mod tests {
"https://integrate.api.nvidia.com/v1",
"/v1/chat/completions",
);
assert_eq!(
url,
"https://integrate.api.nvidia.com/v1/chat/completions"
);
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
// 另一个场景:/v1 + /v1/messages
let url2 = adapter.build_url("https://api.example.com/v1", "/v1/messages");
assert_eq!(url2, "https://api.example.com/v1/messages?beta=true");
assert_eq!(url2, "https://api.example.com/v1/messages");
}
#[test]
+7 -16
View File
@@ -144,7 +144,7 @@ fn build_runtime_like_url(
is_proxy: bool,
) -> String {
match app_type {
// Claude 代理预览需要展示运行时附加参数(如 ?beta=true
// Claude 代理预览需要展示运行时一致的 URL 归一化结果
AppType::Claude if is_proxy => ClaudeAdapter::new().build_url(base_url, endpoint),
// Codex/OpenCode 预览复用运行时 /v1 归一化规则
AppType::Codex | AppType::OpenCode => CodexAdapter::new().build_url(base_url, endpoint),
@@ -226,7 +226,7 @@ pub fn check_proxy_requirement(
}
}
// 如果直连地址和代理地址路径不同,需要代理(忽略查询参数差异,如 Claude ?beta=true
// 如果直连地址和代理地址路径不同,需要代理(忽略查询参数差异)
let (direct_base, _) = split_url_suffix(&preview.direct_url);
let (proxy_base, _) = split_url_suffix(&preview.proxy_url);
if direct_base != proxy_base {
@@ -248,10 +248,7 @@ mod tests {
Some("anthropic"),
);
assert_eq!(preview.direct_url, "https://api.example.com/v1/messages");
assert_eq!(
preview.proxy_url,
"https://api.example.com/v1/messages?beta=true"
);
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
assert!(!preview.is_full_url);
}
@@ -282,10 +279,7 @@ mod tests {
preview.direct_url,
"https://api.example.com/v1/messages/v1/messages"
);
assert_eq!(
preview.proxy_url,
"https://api.example.com/v1/messages?beta=true"
);
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
assert!(preview.is_full_url);
}
@@ -397,11 +391,8 @@ mod tests {
Some("anthropic"),
);
assert_eq!(preview.direct_url, "https://api.example.com/v1/v1/messages");
// 代理地址按运行时规则构建,会去重并附加 ?beta=true
assert_eq!(
preview.proxy_url,
"https://api.example.com/v1/messages?beta=true"
);
// 代理地址按运行时规则构建,会进行路径去重
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
}
#[test]
@@ -431,7 +422,7 @@ mod tests {
);
assert_eq!(
preview.proxy_url,
"https://api.example.com/v1/messages?beta=true#frag"
"https://api.example.com/v1/messages#frag"
);
assert!(preview.is_full_url);
}
+3 -3
View File
@@ -285,11 +285,11 @@ impl StreamCheckService {
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
// 健康检查不强制附加查询参数,保持与默认 Claude 路径一致
let url = if base.ends_with("/v1") {
format!("{base}/messages?beta=true")
format!("{base}/messages")
} else {
format!("{base}/v1/messages?beta=true")
format!("{base}/v1/messages")
};
let body = json!({