From f4e2c28a2bac83fa2bd90236465cbd6f40c0b655 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 30 May 2026 21:42:16 +0800 Subject: [PATCH] Enrich Codex proxy forwarding-error responses with context When forward_with_retry fails for Codex endpoints (/chat/completions, /responses, /responses/compact), the handlers previously returned the bare ProxyError, surfacing a terse body to the client. Build a richer JSON error instead: the message embeds provider, model, endpoint and upstream status, and the error object carries those as structured fields alongside a stable cc_switch_* code. Align map_proxy_error_to_status with ProxyError::into_response so the manually built responses use the canonical status codes (AuthError->401, Config/InvalidRequest->400, TransformError->422, StreamIdleTimeout->504, AlreadyRunning->409, NotRunning->503); previously these forwarder-level errors collapsed to 500. --- src-tauri/src/proxy/error_mapper.rs | 45 +++++- src-tauri/src/proxy/handlers.rs | 214 +++++++++++++++++++++++++++- 2 files changed, 251 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/proxy/error_mapper.rs b/src-tauri/src/proxy/error_mapper.rs index f98c9608a..777472ea5 100644 --- a/src-tauri/src/proxy/error_mapper.rs +++ b/src-tauri/src/proxy/error_mapper.rs @@ -1,6 +1,6 @@ //! 错误类型到 HTTP 状态码的映射 //! -//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录 +//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录和手动构建错误响应 use super::ProxyError; @@ -12,14 +12,21 @@ use super::ProxyError; /// - 连接失败:502 Bad Gateway /// - 无可用 Provider:503 Service Unavailable /// - 重试耗尽:503 Service Unavailable +/// - 认证错误:401 Unauthorized +/// - 配置/请求错误:400 Bad Request +/// - 转换错误:422 Unprocessable Entity /// - 其他错误:500 Internal Server Error pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 { match error { + // 服务状态错误:与 IntoResponse 保持一致 + ProxyError::AlreadyRunning => 409, + ProxyError::NotRunning => 503, + // 上游错误:使用实际状态码 ProxyError::UpstreamError { status, .. } => *status, // 超时错误:504 Gateway Timeout - ProxyError::Timeout(_) => 504, + ProxyError::Timeout(_) | ProxyError::StreamIdleTimeout(_) => 504, // 转发失败/连接失败:502 Bad Gateway ProxyError::ForwardFailed(_) => 502, @@ -39,11 +46,17 @@ pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 { // Provider 不健康:503 Service Unavailable ProxyError::ProviderUnhealthy(_) => 503, + // 配置错误/无效请求:400 Bad Request + ProxyError::ConfigError(_) | ProxyError::InvalidRequest(_) => 400, + + // 认证错误:401 Unauthorized + ProxyError::AuthError(_) => 401, + // 数据库错误:500 Internal Server Error ProxyError::DatabaseError(_) => 500, - // 转换错误:500 Internal Server Error - ProxyError::TransformError(_) => 500, + // 转换错误:422 Unprocessable Entity + ProxyError::TransformError(_) => 422, // 其他未知错误:500 Internal Server Error _ => 500, @@ -104,6 +117,30 @@ mod tests { assert_eq!(map_proxy_error_to_status(&error), 503); } + #[test] + fn test_map_status_matches_proxy_error_response_semantics() { + assert_eq!( + map_proxy_error_to_status(&ProxyError::AuthError("bad token".to_string())), + 401 + ); + assert_eq!( + map_proxy_error_to_status(&ProxyError::ConfigError("bad config".to_string())), + 400 + ); + assert_eq!( + map_proxy_error_to_status(&ProxyError::InvalidRequest("bad request".to_string())), + 400 + ); + assert_eq!( + map_proxy_error_to_status(&ProxyError::TransformError("bad transform".to_string())), + 422 + ); + assert_eq!( + map_proxy_error_to_status(&ProxyError::StreamIdleTimeout(30)), + 504 + ); + } + #[test] fn test_get_error_message() { let error = ProxyError::UpstreamError { diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 9e8526052..e69a1ec30 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -522,7 +522,7 @@ pub async fn handle_chat_completions( ctx.provider = provider; } log_forward_error(&state, &ctx, is_stream, &err.error); - return Err(err.error); + return build_codex_proxy_error_response(&ctx, &endpoint, &err.error); } }; @@ -586,7 +586,7 @@ pub async fn handle_responses( ctx.provider = provider; } log_forward_error(&state, &ctx, is_stream, &err.error); - return Err(err.error); + return build_codex_proxy_error_response(&ctx, &endpoint, &err.error); } }; @@ -661,7 +661,7 @@ pub async fn handle_responses_compact( ctx.provider = provider; } log_forward_error(&state, &ctx, is_stream, &err.error); - return Err(err.error); + return build_codex_proxy_error_response(&ctx, &endpoint, &err.error); } }; @@ -926,6 +926,175 @@ async fn handle_codex_chat_error_response( }) } +/// 把转发层(非上游响应)的失败构造成富化的 Codex 错误响应。 +/// +/// 与 `handle_codex_chat_error_response`(处理上游真实错误响应、复制上游头)不同, +/// 这里没有上游响应可参照,只产出一个 `application/json` 错误体。状态码走 +/// `map_proxy_error_to_status`,该函数已与 `ProxyError::into_response` 对齐。 +/// +/// 注意:`endpoint` 经 `endpoint_with_query` 可能携带 query(如 `?beta=true`)并被 +/// 原样写入错误体。当前 Codex 端点不在 query 里放凭证,故安全;若将来复用到 +/// query 携带密钥的端点(如 Gemini 的 `?key=`),需先脱敏再回显。 +fn build_codex_proxy_error_response( + ctx: &RequestContext, + endpoint: &str, + error: &ProxyError, +) -> Result { + let status = axum::http::StatusCode::from_u16(map_proxy_error_to_status(error)) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + let body = codex_proxy_error_json(&ctx.provider.name, &ctx.request_model, endpoint, error); + let body = serde_json::to_vec(&body).map_err(|e| { + log::error!("[Codex] 序列化代理错误体失败: {e}"); + ProxyError::Internal(format!("Failed to serialize proxy error: {e}")) + })?; + + axum::response::Response::builder() + .status(status) + .header( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ) + .body(axum::body::Body::from(body)) + .map_err(|e| { + log::error!("[Codex] 构建代理错误响应失败: {e}"); + ProxyError::Internal(format!("Failed to build proxy error response: {e}")) + }) +} + +fn codex_proxy_error_json( + provider_name: &str, + request_model: &str, + endpoint: &str, + error: &ProxyError, +) -> Value { + let (mut body, upstream_status) = match error { + ProxyError::UpstreamError { status, body } => { + let parsed_body = body + .as_deref() + .map(|body| serde_json::from_str::(body).unwrap_or_else(|_| json!(body))); + ( + transform_codex_chat::chat_error_to_response_error(parsed_body.as_ref()), + Some(*status), + ) + } + _ => ( + json!({ + "error": { + "message": get_error_message(error), + "type": "proxy_error", + "code": codex_proxy_error_code(error), + "param": Value::Null, + } + }), + None, + ), + }; + + let Some(error_obj) = body + .get_mut("error") + .and_then(|value| value.as_object_mut()) + else { + return body; + }; + + let cause = error_obj + .get("message") + .and_then(|value| value.as_str()) + .map(ToString::to_string) + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(|| get_error_message(error)); + + let status_fragment = upstream_status + .map(|status| format!("; upstream_status: HTTP {status}")) + .unwrap_or_default(); + let message = format!( + "CC Switch local proxy failed while handling Codex endpoint {endpoint}. Provider: {provider_name}; model: {request_model}{status_fragment}; cause: {cause}" + ); + + error_obj.insert( + "message".to_string(), + Value::String(compact_error_message(&message, 1800)), + ); + + if error_obj + .get("type") + .and_then(|value| value.as_str()) + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + { + error_obj.insert("type".to_string(), Value::String("proxy_error".to_string())); + } + + if error_obj.get("code").map(Value::is_null).unwrap_or(true) { + error_obj.insert( + "code".to_string(), + Value::String(codex_proxy_error_code(error).to_string()), + ); + } + + if !error_obj.contains_key("param") { + error_obj.insert("param".to_string(), Value::Null); + } + + error_obj.insert( + "provider".to_string(), + Value::String(provider_name.to_string()), + ); + error_obj.insert( + "model".to_string(), + Value::String(request_model.to_string()), + ); + // 仅用于 Codex 本地路由;不要复用到 query 可能携带凭证的端点。 + error_obj.insert("endpoint".to_string(), Value::String(endpoint.to_string())); + if let Some(status) = upstream_status { + error_obj.insert( + "upstream_status".to_string(), + Value::Number(serde_json::Number::from(status)), + ); + } + + body +} + +fn codex_proxy_error_code(error: &ProxyError) -> &'static str { + match error { + ProxyError::ForwardFailed(_) => "cc_switch_forward_failed", + ProxyError::Timeout(_) | ProxyError::StreamIdleTimeout(_) => "cc_switch_timeout", + ProxyError::NoAvailableProvider => "cc_switch_no_available_provider", + ProxyError::AllProvidersCircuitOpen => "cc_switch_all_providers_circuit_open", + ProxyError::NoProvidersConfigured => "cc_switch_no_providers_configured", + ProxyError::MaxRetriesExceeded => "cc_switch_max_retries_exceeded", + ProxyError::ProviderUnhealthy(_) => "cc_switch_provider_unhealthy", + ProxyError::ConfigError(_) => "cc_switch_config_error", + ProxyError::TransformError(_) => "cc_switch_transform_error", + ProxyError::InvalidRequest(_) => "cc_switch_invalid_request", + ProxyError::AuthError(_) => "cc_switch_auth_error", + ProxyError::UpstreamError { .. } => "cc_switch_upstream_error", + ProxyError::DatabaseError(_) => "cc_switch_database_error", + ProxyError::Internal(_) => "cc_switch_internal_error", + ProxyError::AlreadyRunning + | ProxyError::NotRunning + | ProxyError::BindFailed(_) + | ProxyError::StopTimeout + | ProxyError::StopFailed(_) => "cc_switch_proxy_error", + } +} + +fn compact_error_message(message: &str, max_chars: usize) -> String { + let normalized = message.split_whitespace().collect::>().join(" "); + if normalized.chars().count() <= max_chars { + return normalized; + } + + let truncated = normalized + .chars() + .take(max_chars) + .collect::() + .trim_end() + .to_string(); + format!("{truncated}…(truncated)") +} + // ============================================================================ // Gemini API 处理器 // ============================================================================ @@ -1176,7 +1345,10 @@ async fn log_usage( #[cfg(test)] mod tests { - use super::{responses_sse_to_response_value, should_use_claude_transform_streaming}; + use super::{ + codex_proxy_error_json, responses_sse_to_response_value, + should_use_claude_transform_streaming, + }; use crate::proxy::ProxyError; #[test] @@ -1263,4 +1435,38 @@ data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\"}}\n assert!(responses_sse_to_response_value(sse).is_err()); } + + #[test] + fn codex_proxy_forward_error_includes_context_and_cause() { + let error = ProxyError::ForwardFailed("连接失败: dns lookup failed".to_string()); + let body = codex_proxy_error_json("DeepSeek", "deepseek-chat", "/responses", &error); + + let message = body["error"]["message"].as_str().unwrap(); + assert!(message.contains("CC Switch local proxy failed")); + assert!(message.contains("DeepSeek")); + assert!(message.contains("deepseek-chat")); + assert!(message.contains("/responses")); + assert!(message.contains("dns lookup failed")); + assert_eq!(body["error"]["code"], "cc_switch_forward_failed"); + assert_eq!(body["error"]["provider"], "DeepSeek"); + assert_eq!(body["error"]["model"], "deepseek-chat"); + } + + #[test] + fn codex_proxy_upstream_error_normalizes_nonstandard_body() { + let error = ProxyError::UpstreamError { + status: 502, + body: Some( + r#"{"base_resp":{"status_code":2013,"status_msg":"upstream gateway failed"}}"# + .to_string(), + ), + }; + let body = codex_proxy_error_json("MiniMax", "abab6.5s", "/responses", &error); + + let message = body["error"]["message"].as_str().unwrap(); + assert!(message.contains("upstream_status: HTTP 502")); + assert!(message.contains("upstream gateway failed")); + assert_eq!(body["error"]["code"], 2013); + assert_eq!(body["error"]["upstream_status"], 502); + } }