fix: refine OpenAI error mapping

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-03 22:16:17 +08:00
co-authored by Codex
parent 85dc14db3c
commit f4f69df6ee
6 changed files with 128 additions and 73 deletions
+91 -53
View File
@@ -43,6 +43,45 @@ pub struct OpenAiErrorDetails {
pub code: &'static str,
}
#[derive(Debug, Clone, Copy)]
struct OpenAiErrorKind {
error_type: &'static str,
code: &'static str,
}
const UPSTREAM_UNAVAILABLE_ERROR: OpenAiErrorKind = OpenAiErrorKind {
error_type: "server_error",
code: "upstream_unavailable",
};
const UPSTREAM_ERROR: OpenAiErrorKind = OpenAiErrorKind {
error_type: "server_error",
code: "upstream_error",
};
const INSUFFICIENT_QUOTA_ERROR: OpenAiErrorKind = OpenAiErrorKind {
error_type: "insufficient_quota",
code: "insufficient_quota",
};
const RATE_LIMIT_ERROR: OpenAiErrorKind = OpenAiErrorKind {
error_type: "rate_limit_error",
code: "rate_limit_exceeded",
};
const AUTHENTICATION_ERROR: OpenAiErrorKind = OpenAiErrorKind {
error_type: "invalid_request_error",
code: "invalid_api_key",
};
const MODEL_NOT_FOUND_ERROR: OpenAiErrorKind = OpenAiErrorKind {
error_type: "invalid_request_error",
code: "model_not_found",
};
const INVALID_UPSTREAM_RESPONSE_ERROR: OpenAiErrorKind = OpenAiErrorKind {
error_type: "server_error",
code: "invalid_upstream_response",
};
const CODEX_API_ERROR: OpenAiErrorKind = OpenAiErrorKind {
error_type: "server_error",
code: "codex_api_error",
};
/// OpenAI 兼容错误响应。
pub fn openai_error_response(
status: StatusCode,
@@ -103,12 +142,13 @@ pub fn responses_compact_dispatch_error_response(error: ResponseDispatchError) -
}
pub fn responses_stream_dispatch_failed_sse_event(error: &ResponseDispatchError) -> String {
let error = response_dispatch_http_error(
let http_error = response_dispatch_http_error(
error,
ResponseDispatchStatusMode::UpstreamFailureStatus,
ResponseDispatchMessageStyle::ResponsesStream,
);
responses_failed_sse_event(error.status, &error.message)
let kind = response_dispatch_error_kind(error);
responses_failed_sse_event(kind, &http_error.message)
}
fn responses_no_available_accounts_response() -> (StatusCode, Json<Value>) {
@@ -126,14 +166,14 @@ fn responses_no_available_accounts_response() -> (StatusCode, Json<Value>) {
}
fn responses_error_response(status: StatusCode, message: &str) -> (StatusCode, Json<Value>) {
let (error_type, code) = responses_error_type_and_code_for_status(status);
let kind = responses_error_kind_for_status(status);
(
status,
Json(json!({
"type": "error",
"error": {
"type": error_type,
"code": code,
"type": kind.error_type,
"code": kind.code,
"message": message,
}
})),
@@ -146,9 +186,8 @@ fn response_dispatch_openai_error_response(error: &ResponseDispatchError) -> Res
.into_response()
}
fn responses_failed_sse_event(status: StatusCode, message: &str) -> String {
let (error_type, code) = responses_error_type_and_code_for_status(status);
encode_response_failed_sse_event(error_type, code, message)
fn responses_failed_sse_event(kind: OpenAiErrorKind, message: &str) -> String {
encode_response_failed_sse_event(kind.error_type, kind.code, message)
}
/// 缺失 client API key。
@@ -192,48 +231,42 @@ fn invalid_openai_request_response(message: &str) -> (StatusCode, Json<Value>) {
fn chat_dispatch_openai_error(error: &ChatDispatchError) -> OpenAiErrorDetails {
let status = chat_dispatch_status(error);
let (message, error_type, code) = match error {
let (message, kind) = match error {
ChatDispatchError::NoActiveAccount | ChatDispatchError::AccountStore => (
NO_ACTIVE_UPSTREAM_ACCOUNT_MESSAGE.to_owned(),
"server_error",
"upstream_unavailable",
UPSTREAM_UNAVAILABLE_ERROR,
),
ChatDispatchError::Upstream(_) => (
UPSTREAM_CODEX_REQUEST_FAILED_MESSAGE.to_owned(),
"server_error",
"upstream_error",
UPSTREAM_ERROR,
),
ChatDispatchError::QuotaExhausted {
count,
upstream_error,
} => (
exhausted_dispatch_message(*count, "quota-exhausted", upstream_error, false),
"server_error",
"upstream_error",
INSUFFICIENT_QUOTA_ERROR,
),
ChatDispatchError::RateLimited {
count,
upstream_error,
} => (
exhausted_dispatch_message(*count, "rate-limited", upstream_error, false),
"server_error",
"upstream_error",
RATE_LIMIT_ERROR,
),
ChatDispatchError::Expired {
count,
upstream_error,
} => (
exhausted_dispatch_message(*count, "expired", upstream_error, false),
"server_error",
"upstream_error",
AUTHENTICATION_ERROR,
),
ChatDispatchError::Disabled {
count,
upstream_error,
} => (
exhausted_dispatch_message(*count, "disabled", upstream_error, false),
"server_error",
"upstream_error",
AUTHENTICATION_ERROR,
),
ChatDispatchError::Banned {
count,
@@ -241,45 +274,40 @@ fn chat_dispatch_openai_error(error: &ChatDispatchError) -> OpenAiErrorDetails {
..
} => (
exhausted_dispatch_message(*count, "banned", upstream_error, false),
"server_error",
"upstream_error",
AUTHENTICATION_ERROR,
),
ChatDispatchError::CloudflareChallenge {
count,
upstream_error,
} => (
exhausted_dispatch_message(*count, "cloudflare-challenge", upstream_error, false),
"server_error",
"upstream_error",
UPSTREAM_ERROR,
),
ChatDispatchError::CloudflarePathBlocked {
count,
upstream_error,
} => (
exhausted_dispatch_message(*count, "cloudflare-path-block", upstream_error, false),
"server_error",
"upstream_error",
UPSTREAM_ERROR,
),
ChatDispatchError::ModelUnsupported {
count,
upstream_error,
} => (
exhausted_dispatch_message(*count, "model-unsupported", upstream_error, false),
"invalid_request_error",
"upstream_error",
MODEL_NOT_FOUND_ERROR,
),
ChatDispatchError::InvalidSse(_) | ChatDispatchError::EmptyUpstreamResponse => (
INVALID_UPSTREAM_CODEX_RESPONSE_MESSAGE.to_owned(),
"server_error",
"invalid_upstream_response",
INVALID_UPSTREAM_RESPONSE_ERROR,
),
};
OpenAiErrorDetails {
status,
message,
error_type,
code,
error_type: kind.error_type,
code: kind.code,
}
}
@@ -289,32 +317,37 @@ fn response_dispatch_openai_error(error: &ResponseDispatchError) -> OpenAiErrorD
ResponseDispatchStatusMode::UpstreamFailureStatus,
ResponseDispatchMessageStyle::Standard,
);
let (error_type, code) = response_dispatch_error_type_and_code(error);
let kind = response_dispatch_error_kind(error);
OpenAiErrorDetails {
status,
message,
error_type,
code,
error_type: kind.error_type,
code: kind.code,
}
}
fn response_dispatch_error_type_and_code(
error: &ResponseDispatchError,
) -> (&'static str, &'static str) {
fn response_dispatch_error_kind(error: &ResponseDispatchError) -> OpenAiErrorKind {
match error {
ResponseDispatchError::ModelUnsupported { .. } => {
("invalid_request_error", "upstream_error")
ResponseDispatchError::NoActiveAccount | ResponseDispatchError::AccountStore => {
UPSTREAM_UNAVAILABLE_ERROR
}
ResponseDispatchError::QuotaExhausted { .. } => INSUFFICIENT_QUOTA_ERROR,
ResponseDispatchError::RateLimited { .. } => RATE_LIMIT_ERROR,
ResponseDispatchError::Expired { .. }
| ResponseDispatchError::Disabled { .. }
| ResponseDispatchError::Banned { .. } => AUTHENTICATION_ERROR,
ResponseDispatchError::ModelUnsupported { .. } => MODEL_NOT_FOUND_ERROR,
ResponseDispatchError::InvalidSse(_)
| ResponseDispatchError::MissingCompleted
| ResponseDispatchError::EmptyUpstreamResponse => {
("server_error", "invalid_upstream_response")
}
ResponseDispatchError::Failed(_) => responses_error_type_and_code_for_status(
status_from_u16(error.http_status_code(), StatusCode::BAD_GATEWAY),
),
_ => ("server_error", "upstream_error"),
| ResponseDispatchError::EmptyUpstreamResponse => INVALID_UPSTREAM_RESPONSE_ERROR,
ResponseDispatchError::Failed(_) => responses_error_kind_for_status(status_from_u16(
error.http_status_code(),
StatusCode::BAD_GATEWAY,
)),
ResponseDispatchError::Upstream(_)
| ResponseDispatchError::CloudflareChallenge { .. }
| ResponseDispatchError::CloudflarePathBlocked { .. } => UPSTREAM_ERROR,
}
}
@@ -329,15 +362,20 @@ pub fn response_dispatch_http_error(
}
}
fn responses_error_type_and_code_for_status(status: StatusCode) -> (&'static str, &'static str) {
fn responses_error_kind_for_status(status: StatusCode) -> OpenAiErrorKind {
if status == StatusCode::TOO_MANY_REQUESTS {
("rate_limit_error", "rate_limit_exceeded")
RATE_LIMIT_ERROR
} else if status == StatusCode::PAYMENT_REQUIRED {
INSUFFICIENT_QUOTA_ERROR
} else if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
("invalid_request_error", "authentication_error")
AUTHENTICATION_ERROR
} else if status.is_client_error() {
("invalid_request_error", "codex_api_error")
OpenAiErrorKind {
error_type: "invalid_request_error",
code: "codex_api_error",
}
} else {
("server_error", "codex_api_error")
CODEX_API_ERROR
}
}
@@ -552,7 +552,8 @@ async fn chat_completions_should_return_rate_limit_error_when_429_fallback_is_ex
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
assert!(message.contains("All accounts exhausted (1 rate-limited)"));
assert!(message.contains("rate limited"));
assert_eq!(body["error"]["code"], "upstream_error");
assert_eq!(body["error"]["type"], "rate_limit_error");
assert_eq!(body["error"]["code"], "rate_limit_exceeded");
assert_eq!(quota_state.0, 1);
assert!(quota_state.1.is_some());
let event = latest_usage_record(&pool, "v1.chat").await;
@@ -683,7 +684,8 @@ async fn chat_completions_should_return_model_unsupported_error_when_fallback_is
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(message.contains("All accounts exhausted (1 model-unsupported)"));
assert!(message.contains("model_not_supported"));
assert_eq!(body["error"]["code"], "upstream_error");
assert_eq!(body["error"]["type"], "invalid_request_error");
assert_eq!(body["error"]["code"], "model_not_found");
assert_eq!(account_status.0, "active");
}
@@ -787,7 +789,8 @@ async fn chat_completions_should_return_auth_error_when_401_fallback_is_exhauste
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(message.contains("All accounts exhausted (1 expired)"));
assert!(message.contains("token_revoked"));
assert_eq!(body["error"]["code"], "upstream_error");
assert_eq!(body["error"]["type"], "invalid_request_error");
assert_eq!(body["error"]["code"], "invalid_api_key");
assert_eq!(account_status.0, "expired");
}
@@ -1082,7 +1085,8 @@ async fn chat_completions_should_return_quota_error_when_402_fallback_is_exhaust
assert_eq!(status, StatusCode::PAYMENT_REQUIRED);
assert!(message.contains("All accounts exhausted (1 quota-exhausted)"));
assert!(message.contains("quota reached"));
assert_eq!(body["error"]["code"], "upstream_error");
assert_eq!(body["error"]["type"], "insufficient_quota");
assert_eq!(body["error"]["code"], "insufficient_quota");
assert_eq!(account_status.0, "quota_exhausted");
}
@@ -363,7 +363,8 @@ async fn responses_should_return_auth_error_when_401_fallback_is_exhausted() {
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(message.contains("All accounts exhausted (1 expired)"));
assert!(message.contains("token_revoked"));
assert_eq!(body["error"]["code"], "upstream_error");
assert_eq!(body["error"]["type"], "invalid_request_error");
assert_eq!(body["error"]["code"], "invalid_api_key");
assert_eq!(account_status.0, "expired");
}
@@ -1006,6 +1007,7 @@ async fn responses_stream_should_return_model_unsupported_error_when_fallback_is
assert_eq!(status, StatusCode::OK);
assert!(content_type.starts_with("text/event-stream"));
assert!(body.contains("event: response.failed"));
assert!(body.contains("\"code\":\"model_not_found\""));
assert!(body.contains("All accounts exhausted (1 model-unsupported)"));
assert!(body.contains("model_not_available"));
assert_eq!(account_status.0, "active");
@@ -1512,6 +1514,7 @@ async fn responses_stream_should_return_auth_error_when_401_fallback_is_exhauste
assert_eq!(status, StatusCode::OK);
assert!(content_type.starts_with("text/event-stream"));
assert!(body.contains("event: response.failed"));
assert!(body.contains("\"code\":\"invalid_api_key\""));
assert!(body.contains("All accounts exhausted (1 expired)"));
assert!(body.contains("token_revoked"));
assert_eq!(account_status.0, "expired");
@@ -2062,7 +2065,8 @@ async fn responses_should_return_quota_error_when_402_fallback_is_exhausted() {
assert_eq!(status, StatusCode::PAYMENT_REQUIRED);
assert!(message.contains("All accounts exhausted (1 quota-exhausted)"));
assert!(message.contains("quota reached"));
assert_eq!(body["error"]["code"], "upstream_error");
assert_eq!(body["error"]["type"], "insufficient_quota");
assert_eq!(body["error"]["code"], "insufficient_quota");
assert_eq!(account_status.0, "quota_exhausted");
}
@@ -2115,7 +2119,8 @@ async fn responses_should_return_rate_limit_error_when_429_fallback_is_exhausted
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
assert!(message.contains("All accounts exhausted (1 rate-limited)"));
assert!(message.contains("rate limited"));
assert_eq!(body["error"]["code"], "upstream_error");
assert_eq!(body["error"]["type"], "rate_limit_error");
assert_eq!(body["error"]["code"], "rate_limit_exceeded");
assert_eq!(quota_state.0, 1);
assert!(quota_state.1.is_some());
let event = latest_response_usage_record(&pool).await;
@@ -2272,6 +2277,7 @@ async fn responses_stream_should_return_quota_error_when_402_fallback_is_exhaust
assert_eq!(status, StatusCode::OK);
assert!(content_type.starts_with("text/event-stream"));
assert!(body.contains("event: response.failed"));
assert!(body.contains("\"code\":\"insufficient_quota\""));
assert!(body.contains("All accounts exhausted (1 quota-exhausted)"));
assert!(body.contains("quota reached"));
assert_eq!(account_status.0, "quota_exhausted");
@@ -1703,7 +1703,7 @@ async fn responses_websocket_without_history_should_mark_expired_after_fallback_
assert!(body.contains("event: response.failed"));
assert!(body.contains("\"type\":\"invalid_request_error\""));
assert!(body.contains("\"code\":\"authentication_error\""));
assert!(body.contains("\"code\":\"invalid_api_key\""));
assert!(body.contains("All accounts exhausted"));
assert!(body.contains("token_revoked"));
assert_eq!(secondary_status.0, "expired");
@@ -1877,8 +1877,8 @@ async fn responses_websocket_without_history_should_return_quota_stream_error_wh
assert_response_failed_stream(
&body,
"invalid_request_error",
"codex_api_error",
"insufficient_quota",
"insufficient_quota",
&[
"All accounts exhausted (1 quota-exhausted)",
"quota reached",
@@ -1928,7 +1928,7 @@ async fn responses_websocket_without_history_should_return_model_unsupported_str
assert_response_failed_stream(
&body,
"invalid_request_error",
"codex_api_error",
"model_not_found",
&[
"No accounts available",
"model_not_available",
@@ -1973,7 +1973,7 @@ async fn responses_websocket_with_history_should_return_path_block_stream_error_
assert_response_failed_stream(
&body,
"server_error",
"codex_api_error",
"upstream_error",
&["No accounts available", "Cloudflare path-block"],
);
}
+10 -4
View File
@@ -164,10 +164,13 @@ fn chat_completion_from_codex_sse_should_convert_completed_response() {
#[test]
fn chat_completion_from_codex_sse_should_convert_incomplete_response() {
let body = include_str!("../../fixtures/responses/http_sse/chat_delta_incomplete_usage.sse");
let body = format!(
"{}\n",
include_str!("../../fixtures/responses/http_sse/chat_delta_incomplete_usage.sse")
);
let response = codex_proxy_rs::proxy::openai::chat::chat_completion_from_codex_sse(
body, "gpt-5.5", false, None,
&body, "gpt-5.5", false, None,
)
.expect("conversion should succeed")
.expect("terminal response");
@@ -214,12 +217,15 @@ fn chat_completion_stream_translator_should_complete_incomplete_response() {
let mut translator = codex_proxy_rs::proxy::openai::chat::ChatCompletionStreamTranslator::new(
"gpt-5.5", false, None,
);
let body = include_str!("../../fixtures/responses/http_sse/chat_delta_incomplete_usage.sse");
let body = format!(
"{}\n",
include_str!("../../fixtures/responses/http_sse/chat_delta_incomplete_usage.sse")
);
let output = format!(
"{}{}",
translator.initial_frame(),
translator
.push_str(body)
.push_str(&body)
.expect("stream conversion should succeed")
);
+5 -4
View File
@@ -84,11 +84,11 @@ response.output_item.done
错误体:
- **quota 耗尽 → HTTP 402**(`dispatch/responses.rs` `http_status_code`、`chat.rs` 同):OpenAI 用 429 + `insufficient_quota`。**判为设计权衡而非 bug** — 402 Payment Required 语义上对应配额耗尽。但若目标是兼容 OpenAI SDK 的限流退避/重试,SDK 的 status→异常映射无 402 分支,会落到通用 `APIStatusError` 而非 `RateLimitError`,退避逻辑失效。取决于产品意图,此处仅标注权衡点。
- 聚合错误 type 恒为 `server_error`/`upstream_error`(`errors.rs`):quota/rate 耗尽经账户池聚合路径时 type 未按语义映射为 `insufficient_quota`/`rate_limit_error`,读 `error.type` 判限流的客户端无法识别。
- 聚合错误 type/code 语义映射(已修复):quota/rate/auth/model unsupported 经账户池聚合路径时已分别映射为 `insufficient_quota`、`rate_limit_error/rate_limit_exceeded`、`invalid_request_error/invalid_api_key`、`invalid_request_error/model_not_found`。
- 错误体缺 `param` 字段(`errors.rs:47-63`);多数 SDK 容忍。
- Responses 非流式错误多包一层顶层 `"type":"error"`(`errors.rs:114-141`),偏离 `{"error":{...}}`。
- 401(expired/disabled) 聚合路径 status=401 但 type=`server_error`,body 与状态码不自洽。
- chat 流式中途失败帧 type=`stream_error` 且无 `code`(`chat.rs:1001`),OpenAI SDK 不识别,等价于坏流。
- Responses stream 聚合错误 `response.failed` 已复用 dispatch 语义 code,不再仅按 HTTP status 粗映射;chat 流式中途失败帧仍是 type=`stream_error` 且无 `code`(`chat.rs:1001`),OpenAI SDK 不识别,等价于坏流。
SSE / chat 转换:
@@ -120,8 +120,9 @@ Models 端点(结构层面与 OpenAI 契约高度一致):
1. **修 `response.incomplete`(🔴,基准硬,已完成)** — 两条 SSE 路径已纳入终止事件集,Chat Completions 已补 `length` finish reason 和 `[DONE]`。
2. **chat 工具定义/`tool_choice` 转扁平(🔴,已完成)** — 已参考 sub2api 与 CLIProxyAPI 的 common path,并补充请求转换测试。
3. **chat reasoning/custom tool delta 与 `output_item.done` 兜底(🟡,已完成)** — 已补 Chat 流式/非流式转换测试。
4. **决策 quota→402 vs 429(🟡,产品权衡)** — 明确是要 OpenAI SDK 兼容还是语义化 HTTP 码。
5. 处理聚合错误 type 语义映射、Models 首拉为空/别名详情等中等项。
4. **聚合错误 type/code 与 Responses stream code 映射(🟡,已完成)** — Chat/Responses JSON 与 Responses `response.failed` 已共享语义化错误 code;HTTP status 策略未在本项中改变。
5. **决策 quota→402 vs 429(🟡,产品权衡)** — 明确是要 OpenAI SDK 兼容还是语义化 HTTP 码。
6. 处理 chat stream 中途失败帧、Models 首拉为空/别名详情等中等项。
## 复现方法(解包基准)