mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
feat: convert Codex Chat error responses to Responses envelope
The Codex Chat-to-Responses bridge already rewrites successful upstream
responses to the Responses shape, but the error branch in
handle_codex_chat_to_responses_transform passed Chat-shaped error bodies
through untouched (MiniMax base_resp, raw OpenAI Chat error, text/plain
"Unauthorized" pages, etc.), leaving Codex clients unable to recognize the
error.
Add chat_error_to_response_error in transform_codex_chat to regularize all
upstream error shapes into the standard {error: {message, type, code, param}}
envelope, then wire it through a new handle_codex_chat_error_response that
preserves the original HTTP status code. Non-JSON error bodies (HTML, plain
text) are wrapped as Value::String and truncated to 1KB at a UTF-8 char
boundary to keep diagnostic context without flooding the response.
Also fix a pre-existing append-vs-insert pitfall in three rebuilt-body
branches (Claude transform, Codex Chat normal, Codex Chat error): http
Builder::header is append, so leaking the upstream Content-Type produced
two Content-Type headers when the rewritten body was JSON. Remove the
upstream value before writing application/json.
This commit is contained in:
@@ -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<axum::response::Response, ProxyError> {
|
||||
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::<Value>(&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 处理器
|
||||
// ============================================================================
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user