diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index ee66edfa5..9e8526052 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -441,12 +441,17 @@ async fn handle_claude_transform( let mut builder = axum::response::Response::builder().status(status); strip_entity_headers_for_rebuilt_body(&mut response_headers); strip_hop_by_hop_response_headers(&mut response_headers); + // Builder::header 是 append 语义;不先 remove 会和上游 Content-Type 双发。 + response_headers.remove(axum::http::header::CONTENT_TYPE); for (key, value) in response_headers.iter() { builder = builder.header(key, value); } - builder = builder.header("content-type", "application/json"); + builder = builder.header( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ); let response_body = serde_json::to_vec(&anthropic_response).map_err(|e| { log::error!("[Claude] 序列化响应失败: {e}"); @@ -695,8 +700,10 @@ async fn handle_codex_chat_to_responses_transform( let status = response.status(); if !status.is_success() { - return process_response(response, ctx, state, &CODEX_PARSER_CONFIG, connection_guard) - .await; + // 上游 Chat 错误体形状与 Responses 不一致(如 MiniMax 的 base_resp、自定义 detail 字段); + // 直接透传会让 Codex 客户端无法识别错误码。这里统一转换为 Responses 风格 + // `{"error": {message, type, code, param}}`,保留原始 HTTP 状态码。 + return handle_codex_chat_error_response(response, ctx, status).await; } if is_stream || response.is_sse() { @@ -826,12 +833,17 @@ async fn handle_codex_chat_to_responses_transform( strip_entity_headers_for_rebuilt_body(&mut response_headers); strip_hop_by_hop_response_headers(&mut response_headers); + // Builder::header 是 append 语义;不先 remove 会和上游 Content-Type 双发。 + response_headers.remove(axum::http::header::CONTENT_TYPE); let mut builder = axum::response::Response::builder().status(status); for (key, value) in response_headers.iter() { builder = builder.header(key, value); } - builder = builder.header("content-type", "application/json"); + builder = builder.header( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ); let response_body = serde_json::to_vec(&responses_response).map_err(|e| { log::error!("[Codex] 序列化 Responses 响应失败: {e}"); @@ -846,6 +858,74 @@ async fn handle_codex_chat_to_responses_transform( }) } +/// 把上游 Chat Completions 的错误响应转换为 Responses API 错误形状。 +/// +/// 与正常响应分支配套:正常响应已经被改写成 Responses 形式,错误响应若仍保留 +/// Chat 错误体(如 MiniMax 的 `{"base_resp": {"status_code": 2013}}`),Codex +/// 客户端的错误处理就无法对齐字段。这里读取上游 body、规整成 +/// `{"error": {message, type, code, param}}` 并保留原始 HTTP 状态码。 +async fn handle_codex_chat_error_response( + response: super::hyper_client::ProxyResponse, + ctx: &RequestContext, + status: axum::http::StatusCode, +) -> Result { + let body_timeout = + if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 { + std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64) + } else { + std::time::Duration::ZERO + }; + let (mut response_headers, _status, body_bytes) = + read_decoded_body(response, ctx.tag, body_timeout).await?; + + // 非 JSON 上游错误体(Cloudflare HTML、纯文本 "Unauthorized" 等)若丢成 None, + // 客户端就看不到原始诊断信息;包成 Value::String 走转换函数的字符串分支。 + let parsed_value: Value = match serde_json::from_slice::(&body_bytes) { + Ok(value) => value, + Err(_) => { + const MAX_RAW_ERROR_BYTES: usize = 1024; + let lossy = String::from_utf8_lossy(&body_bytes); + let truncated = if lossy.len() > MAX_RAW_ERROR_BYTES { + let mut end = MAX_RAW_ERROR_BYTES; + while end > 0 && !lossy.is_char_boundary(end) { + end -= 1; + } + format!("{}…(truncated)", &lossy[..end]) + } else { + lossy.into_owned() + }; + log::warn!("[Codex] Chat 错误响应不是合法 JSON,按文本透传: {truncated}"); + Value::String(truncated) + } + }; + + let responses_error = transform_codex_chat::chat_error_to_response_error(Some(&parsed_value)); + + strip_entity_headers_for_rebuilt_body(&mut response_headers); + strip_hop_by_hop_response_headers(&mut response_headers); + // Builder::header 是 append 语义;不先 remove 会和上游 Content-Type 双发。 + response_headers.remove(axum::http::header::CONTENT_TYPE); + + let mut builder = axum::response::Response::builder().status(status); + for (key, value) in response_headers.iter() { + builder = builder.header(key, value); + } + builder = builder.header( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ); + + let body = serde_json::to_vec(&responses_error).map_err(|e| { + log::error!("[Codex] 序列化 Responses 错误体失败: {e}"); + ProxyError::TransformError(format!("Failed to serialize responses error: {e}")) + })?; + + builder.body(axum::body::Body::from(body)).map_err(|e| { + log::error!("[Codex] 构建 Responses 错误响应失败: {e}"); + ProxyError::Internal(format!("Failed to build response: {e}")) + }) +} + // ============================================================================ // Gemini API 处理器 // ============================================================================ diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index f7b9d98ea..72f360207 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -870,6 +870,80 @@ pub(crate) fn response_status_from_finish_reason(finish_reason: Option<&str>) -> } } +/// 把 Chat Completions 上游的错误体规整成 OpenAI Responses API 风格的错误对象。 +/// +/// 兼容三类输入: +/// 1. 标准 OpenAI 形式 `{"error": {"message": "...", "type": "...", "code": ...}}` +/// 2. MiniMax 等非标形式(如 `{"base_resp": {"status_code": 2013, "status_msg": "..."}}`) +/// 3. 顶层只有 `message` / `detail` / 裸字符串的最小错误 +/// +/// 输出统一为 `{"error": {"message", "type", "code", "param"}}`,与 OpenAI Responses +/// API 错误响应一致;Codex 客户端的错误处理只识别这个形状。 +pub fn chat_error_to_response_error(body: Option<&Value>) -> Value { + let Some(value) = body else { + return json!({ + "error": { + "message": "Upstream returned an empty error response", + "type": "upstream_error", + "code": serde_json::Value::Null, + "param": serde_json::Value::Null, + } + }); + }; + + if let Some(text) = value.as_str() { + return json!({ + "error": { + "message": text, + "type": "upstream_error", + "code": serde_json::Value::Null, + "param": serde_json::Value::Null, + } + }); + } + + let source = value.get("error").unwrap_or(value); + + let message = source + .get("message") + .or_else(|| source.get("detail")) + .or_else(|| source.get("status_msg")) + .or_else(|| source.pointer("/base_resp/status_msg")) + .and_then(|v| v.as_str()) + .map(ToString::to_string) + .or_else(|| source.as_str().map(ToString::to_string)) + .unwrap_or_else(|| { + // 没法从字段提取出文本,就把整个 JSON 序列化回去,方便用户排查。 + serde_json::to_string(source).unwrap_or_else(|_| "Upstream error".to_string()) + }); + + let error_type = source + .get("type") + .and_then(|v| v.as_str()) + .map(ToString::to_string) + .unwrap_or_else(|| "upstream_error".to_string()); + + let code = source + .get("code") + .cloned() + .or_else(|| source.pointer("/base_resp/status_code").cloned()) + .unwrap_or(serde_json::Value::Null); + + let param = source + .get("param") + .cloned() + .unwrap_or(serde_json::Value::Null); + + json!({ + "error": { + "message": message, + "type": error_type, + "code": code, + "param": param, + } + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1458,4 +1532,80 @@ mod tests { assert_eq!(result["status"], "incomplete"); assert_eq!(result["incomplete_details"]["reason"], "max_output_tokens"); } + + #[test] + fn chat_error_to_response_error_normalizes_standard_openai_shape() { + let input = json!({ + "error": { + "message": "Invalid API key", + "type": "invalid_request_error", + "code": "invalid_api_key", + "param": "api_key" + } + }); + + let result = chat_error_to_response_error(Some(&input)); + + assert_eq!(result["error"]["message"], "Invalid API key"); + assert_eq!(result["error"]["type"], "invalid_request_error"); + assert_eq!(result["error"]["code"], "invalid_api_key"); + assert_eq!(result["error"]["param"], "api_key"); + } + + #[test] + fn chat_error_to_response_error_normalizes_minimax_base_resp() { + // MiniMax 把错误塞在 base_resp 里,code 是数字而不是字符串 + let input = json!({ + "base_resp": { + "status_code": 2013, + "status_msg": "invalid params, chat content has invalid message role: system" + } + }); + + let result = chat_error_to_response_error(Some(&input)); + + assert_eq!( + result["error"]["message"], + "invalid params, chat content has invalid message role: system" + ); + assert_eq!(result["error"]["code"], 2013); + // type 没有显式给出,应该回落到 upstream_error + assert_eq!(result["error"]["type"], "upstream_error"); + } + + #[test] + fn chat_error_to_response_error_handles_plain_text_body() { + let input = json!("Upstream timeout"); + + let result = chat_error_to_response_error(Some(&input)); + + assert_eq!(result["error"]["message"], "Upstream timeout"); + assert_eq!(result["error"]["type"], "upstream_error"); + assert!(result["error"]["code"].is_null()); + assert!(result["error"]["param"].is_null()); + } + + #[test] + fn chat_error_to_response_error_handles_missing_body() { + let result = chat_error_to_response_error(None); + + assert_eq!( + result["error"]["message"], + "Upstream returned an empty error response" + ); + assert_eq!(result["error"]["type"], "upstream_error"); + } + + #[test] + fn chat_error_to_response_error_falls_back_to_detail_field() { + // 部分中转把错误塞在顶层 detail 字段(OpenAI 兼容层常见) + let input = json!({ + "detail": "rate limit exceeded" + }); + + let result = chat_error_to_response_error(Some(&input)); + + assert_eq!(result["error"]["message"], "rate limit exceeded"); + assert_eq!(result["error"]["type"], "upstream_error"); + } }