From 665d34609d9ce207042d356e59e74e2bda35bab7 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 1 Dec 2025 15:58:30 +0800 Subject: [PATCH] fix(proxy): resolve clippy warnings for dead code and uninlined format args - Add #[allow(dead_code)] to unused ProviderUnhealthy variant - Inline format string arguments in handlers.rs and codex.rs log macros - Refactor error response handling to properly pass through upstream errors - Add URL deduplication logic for /v1/v1 paths in CodexAdapter --- src-tauri/src/proxy/error.rs | 102 +++++++++--- src-tauri/src/proxy/handlers.rs | 104 +++++++++++- src-tauri/src/proxy/providers/codex.rs | 215 +++++++++++++++++++++++++ 3 files changed, 389 insertions(+), 32 deletions(-) create mode 100644 src-tauri/src/proxy/providers/codex.rs diff --git a/src-tauri/src/proxy/error.rs b/src-tauri/src/proxy/error.rs index d1bd4f57e..a571aca38 100644 --- a/src-tauri/src/proxy/error.rs +++ b/src-tauri/src/proxy/error.rs @@ -23,6 +23,7 @@ pub enum ProxyError { #[error("无可用的Provider")] NoAvailableProvider, + #[allow(dead_code)] #[error("Provider不健康: {0}")] ProviderUnhealthy(String), @@ -56,34 +57,83 @@ pub enum ProxyError { impl IntoResponse for ProxyError { fn into_response(self) -> Response { - let (status, message) = match &self { - ProxyError::AlreadyRunning => (StatusCode::CONFLICT, self.to_string()), - ProxyError::NotRunning => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()), - ProxyError::BindFailed(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), - ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()), - ProxyError::NoAvailableProvider => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()), - ProxyError::ProviderUnhealthy(_) => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()), - ProxyError::UpstreamError { status, .. } => ( - StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY), - self.to_string(), - ), - ProxyError::MaxRetriesExceeded => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()), - ProxyError::DatabaseError(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), - ProxyError::ConfigError(_) => (StatusCode::BAD_REQUEST, self.to_string()), - ProxyError::TransformError(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()), - ProxyError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), - ProxyError::Timeout(_) => (StatusCode::GATEWAY_TIMEOUT, self.to_string()), - ProxyError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), + let (status, body) = match &self { + ProxyError::UpstreamError { + status: upstream_status, + body: upstream_body, + } => { + let http_status = + StatusCode::from_u16(*upstream_status).unwrap_or(StatusCode::BAD_GATEWAY); + + // 尝试解析上游响应体为 JSON,如果失败则包装为字符串 + let error_body = if let Some(body_str) = upstream_body { + if let Ok(json_body) = serde_json::from_str::(body_str) { + // 上游返回的是 JSON,直接透传 + json_body + } else { + // 上游返回的不是 JSON,包装为错误消息 + json!({ + "error": { + "message": body_str, + "type": "upstream_error", + } + }) + } + } else { + json!({ + "error": { + "message": format!("Upstream error (status {})", upstream_status), + "type": "upstream_error", + } + }) + }; + + (http_status, error_body) + } + _ => { + let (http_status, message) = match &self { + ProxyError::AlreadyRunning => (StatusCode::CONFLICT, self.to_string()), + ProxyError::NotRunning => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()), + ProxyError::BindFailed(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) + } + ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()), + ProxyError::NoAvailableProvider => { + (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) + } + ProxyError::ProviderUnhealthy(_) => { + (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) + } + ProxyError::MaxRetriesExceeded => { + (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) + } + ProxyError::DatabaseError(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) + } + ProxyError::ConfigError(_) => (StatusCode::BAD_REQUEST, self.to_string()), + ProxyError::TransformError(_) => { + (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) + } + ProxyError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), + ProxyError::Timeout(_) => (StatusCode::GATEWAY_TIMEOUT, self.to_string()), + ProxyError::Internal(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) + } + ProxyError::UpstreamError { .. } => unreachable!(), + }; + + let error_body = json!({ + "error": { + "message": message, + "type": "proxy_error", + } + }); + + (http_status, error_body) + } }; - let body = Json(json!({ - "error": { - "message": message, - "type": "proxy_error", - } - })); - - (status, body).into_response() + (status, Json(body)).into_response() } } diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index bcc08fbdb..f677eecb0 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -2,7 +2,13 @@ //! //! 处理各种API端点的HTTP请求 -use super::{forwarder::RequestForwarder, server::ProxyState, types::*, ProxyError}; +use super::{ + forwarder::RequestForwarder, + providers::{get_adapter, transform}, + server::ProxyState, + types::*, + ProxyError, +}; use crate::app_config::AppType; use axum::{extract::State, http::StatusCode, Json}; use serde_json::{json, Value}; @@ -35,11 +41,27 @@ pub async fn handle_messages( // 选择目标 Provider let router = super::router::ProviderRouter::new(state.db.clone()); let failed_ids = Vec::new(); - let _provider = router + let provider = router .select_provider(&AppType::Claude, &failed_ids) .await?; - // 直接透传 Claude 请求 + // 检查是否需要转换(OpenRouter) + let adapter = get_adapter(&AppType::Claude); + let needs_transform = adapter.needs_transform(&provider); + + // 检查是否是流式请求 + let is_stream = body + .get("stream") + .and_then(|s| s.as_bool()) + .unwrap_or(false); + + log::info!( + "[Claude] Provider: {}, needs_transform: {}, is_stream: {}", + provider.name, + needs_transform, + is_stream + ); + let forwarder = RequestForwarder::new( state.db.clone(), config.request_timeout, @@ -51,16 +73,86 @@ pub async fn handle_messages( .forward_with_retry(&AppType::Claude, "/v1/messages", body, headers) .await?; - // 透传响应 + let status = response.status(); + log::info!("[Claude] 上游响应状态: {status}"); + + // 如果需要转换且是非流式请求,转换响应 + if needs_transform && !is_stream { + log::info!("[Claude] 开始转换响应 (OpenAI → Anthropic)"); + + let response_headers = response.headers().clone(); + + // 读取响应体 + let body_bytes = response.bytes().await.map_err(|e| { + log::error!("[Claude] 读取响应体失败: {e}"); + ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) + })?; + + let body_str = String::from_utf8_lossy(&body_bytes); + log::info!("[Claude] OpenAI 响应长度: {} bytes", body_bytes.len()); + log::debug!("[Claude] OpenAI 原始响应: {body_str}"); + + // 解析并转换 + let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| { + log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}"); + ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}")) + })?; + + log::info!("[Claude] 解析 OpenAI 响应成功"); + + let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| { + log::error!("[Claude] 转换响应失败: {e}"); + e + })?; + + log::info!("[Claude] 转换响应成功"); + log::info!( + "[Claude] Anthropic 响应: {}", + serde_json::to_string(&anthropic_response).unwrap_or_default() + ); + + // 构建响应 + let mut builder = axum::response::Response::builder().status(status); + + // 复制响应头(排除 content-length,因为内容已改变) + for (key, value) in response_headers.iter() { + if key.as_str().to_lowercase() != "content-length" + && key.as_str().to_lowercase() != "transfer-encoding" + { + builder = builder.header(key, value); + } + } + + builder = builder.header("content-type", "application/json"); + + let response_body = serde_json::to_vec(&anthropic_response).map_err(|e| { + log::error!("[Claude] 序列化响应失败: {e}"); + ProxyError::TransformError(format!("Failed to serialize response: {e}")) + })?; + + log::info!( + "[Claude] 返回转换后的响应, 长度: {} bytes", + response_body.len() + ); + + let body = axum::body::Body::from(response_body); + return Ok(builder.body(body).unwrap()); + } + + // 流式请求需要特殊处理 + if needs_transform && is_stream { + log::warn!("[Claude] OpenRouter 流式请求暂不支持完整转换,透传响应"); + } + + // 透传响应(直连 Anthropic 或流式请求) + log::info!("[Claude] 透传响应"); let mut builder = axum::response::Response::builder().status(response.status()); - // 复制响应头 for (key, value) in response.headers() { builder = builder.header(key, value); } let body = axum::body::Body::from_stream(response.bytes_stream()); - Ok(builder.body(body).unwrap()) } diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs new file mode 100644 index 000000000..1ece7e2fb --- /dev/null +++ b/src-tauri/src/proxy/providers/codex.rs @@ -0,0 +1,215 @@ +//! Codex (OpenAI) Provider Adapter +//! +//! 仅透传模式,支持直连 OpenAI API + +use super::{AuthInfo, AuthStrategy, ProviderAdapter}; +use crate::provider::Provider; +use crate::proxy::error::ProxyError; +use reqwest::RequestBuilder; + +/// Codex 适配器 +pub struct CodexAdapter; + +impl CodexAdapter { + pub fn new() -> Self { + Self + } + + /// 从 Provider 配置中提取 API Key + fn extract_key(&self, provider: &Provider) -> Option { + // 1. 尝试从 env 中获取 + if let Some(env) = provider.settings_config.get("env") { + if let Some(key) = env.get("OPENAI_API_KEY").and_then(|v| v.as_str()) { + return Some(key.to_string()); + } + } + + // 2. 尝试从 auth 中获取 (Codex CLI 格式) + if let Some(auth) = provider.settings_config.get("auth") { + if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) { + return Some(key.to_string()); + } + } + + // 3. 尝试直接获取 + if let Some(key) = provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|v| v.as_str()) + { + return Some(key.to_string()); + } + + // 4. 尝试从 config 对象中获取 + if let Some(config) = provider.settings_config.get("config") { + if let Some(key) = config + .get("api_key") + .or_else(|| config.get("apiKey")) + .and_then(|v| v.as_str()) + { + return Some(key.to_string()); + } + } + + None + } +} + +impl Default for CodexAdapter { + fn default() -> Self { + Self::new() + } +} + +impl ProviderAdapter for CodexAdapter { + fn name(&self) -> &'static str { + "Codex" + } + + fn extract_base_url(&self, provider: &Provider) -> Result { + // 1. 尝试直接获取 base_url 字段 + if let Some(url) = provider + .settings_config + .get("base_url") + .and_then(|v| v.as_str()) + { + return Ok(url.trim_end_matches('/').to_string()); + } + + // 2. 尝试 baseURL + if let Some(url) = provider + .settings_config + .get("baseURL") + .and_then(|v| v.as_str()) + { + return Ok(url.trim_end_matches('/').to_string()); + } + + // 3. 尝试从 config 对象中获取 + if let Some(config) = provider.settings_config.get("config") { + if let Some(url) = config.get("base_url").and_then(|v| v.as_str()) { + return Ok(url.trim_end_matches('/').to_string()); + } + + // 尝试解析 TOML 字符串格式 + if let Some(config_str) = config.as_str() { + if let Some(start) = config_str.find("base_url = \"") { + let rest = &config_str[start + 12..]; + if let Some(end) = rest.find('"') { + return Ok(rest[..end].trim_end_matches('/').to_string()); + } + } + if let Some(start) = config_str.find("base_url = '") { + let rest = &config_str[start + 12..]; + if let Some(end) = rest.find('\'') { + return Ok(rest[..end].trim_end_matches('/').to_string()); + } + } + } + } + + Err(ProxyError::ConfigError( + "Codex Provider 缺少 base_url 配置".to_string(), + )) + } + + fn extract_auth(&self, provider: &Provider) -> Option { + self.extract_key(provider) + .map(|key| AuthInfo::new(key, AuthStrategy::Bearer)) + } + + fn build_url(&self, base_url: &str, endpoint: &str) -> String { + let base_trimmed = base_url.trim_end_matches('/'); + let endpoint_trimmed = endpoint.trim_start_matches('/'); + + let mut url = format!("{base_trimmed}/{endpoint_trimmed}"); + + // 去除重复的 /v1/v1 + if url.contains("/v1/v1") { + url = url.replace("/v1/v1", "/v1"); + } + + url + } + + fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder { + request.header("Authorization", format!("Bearer {}", auth.api_key)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn create_provider(config: serde_json::Value) -> Provider { + Provider { + id: "test".to_string(), + name: "Test Codex".to_string(), + settings_config: config, + website_url: None, + category: Some("codex".to_string()), + created_at: None, + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + is_proxy_target: None, + } + } + + #[test] + fn test_extract_base_url_direct() { + let adapter = CodexAdapter::new(); + let provider = create_provider(json!({ + "base_url": "https://api.openai.com/v1" + })); + + let url = adapter.extract_base_url(&provider).unwrap(); + assert_eq!(url, "https://api.openai.com/v1"); + } + + #[test] + fn test_extract_auth_from_auth_field() { + let adapter = CodexAdapter::new(); + let provider = create_provider(json!({ + "auth": { + "OPENAI_API_KEY": "sk-test-key-12345678" + } + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.api_key, "sk-test-key-12345678"); + assert_eq!(auth.strategy, AuthStrategy::Bearer); + } + + #[test] + fn test_extract_auth_from_env() { + let adapter = CodexAdapter::new(); + let provider = create_provider(json!({ + "env": { + "OPENAI_API_KEY": "sk-env-key-12345678" + } + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.api_key, "sk-env-key-12345678"); + } + + #[test] + fn test_build_url() { + let adapter = CodexAdapter::new(); + let url = adapter.build_url("https://api.openai.com/v1", "/responses"); + assert_eq!(url, "https://api.openai.com/v1/responses"); + } + + #[test] + fn test_build_url_dedup_v1() { + let adapter = CodexAdapter::new(); + // base_url 已包含 /v1,endpoint 也包含 /v1 + let url = adapter.build_url("https://www.packyapi.com/v1", "/v1/responses"); + assert_eq!(url, "https://www.packyapi.com/v1/responses"); + } +}