diff --git a/backend/src/proxy/openai/errors.rs b/backend/src/proxy/openai/errors.rs index b8ca27951..2fe4f9539 100644 --- a/backend/src/proxy/openai/errors.rs +++ b/backend/src/proxy/openai/errors.rs @@ -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) { @@ -126,14 +166,14 @@ fn responses_no_available_accounts_response() -> (StatusCode, Json) { } fn responses_error_response(status: StatusCode, message: &str) -> (StatusCode, Json) { - 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) { 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 } } diff --git a/backend/tests/proxy/dispatch/chat_upstream/chat_routes.rs b/backend/tests/proxy/dispatch/chat_upstream/chat_routes.rs index 6bf6d0353..e9222d775 100644 --- a/backend/tests/proxy/dispatch/chat_upstream/chat_routes.rs +++ b/backend/tests/proxy/dispatch/chat_upstream/chat_routes.rs @@ -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"); } diff --git a/backend/tests/proxy/dispatch/chat_upstream/responses_recovery.rs b/backend/tests/proxy/dispatch/chat_upstream/responses_recovery.rs index 22010aa50..b082ae68b 100644 --- a/backend/tests/proxy/dispatch/chat_upstream/responses_recovery.rs +++ b/backend/tests/proxy/dispatch/chat_upstream/responses_recovery.rs @@ -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"); diff --git a/backend/tests/proxy/dispatch/chat_upstream/responses_websocket.rs b/backend/tests/proxy/dispatch/chat_upstream/responses_websocket.rs index dbe6c56c4..957a2db86 100644 --- a/backend/tests/proxy/dispatch/chat_upstream/responses_websocket.rs +++ b/backend/tests/proxy/dispatch/chat_upstream/responses_websocket.rs @@ -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"], ); } diff --git a/backend/tests/upstream/protocol/openai_chat.rs b/backend/tests/upstream/protocol/openai_chat.rs index b8b63ae1d..6fc72726d 100644 --- a/backend/tests/upstream/protocol/openai_chat.rs +++ b/backend/tests/upstream/protocol/openai_chat.rs @@ -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") ); diff --git a/docs/protocol-fidelity-audit-2026-07-03.md b/docs/protocol-fidelity-audit-2026-07-03.md index 526b9bf87..aefdb7129 100644 --- a/docs/protocol-fidelity-audit-2026-07-03.md +++ b/docs/protocol-fidelity-audit-2026-07-03.md @@ -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 首拉为空/别名详情等中等项。 ## 复现方法(解包基准)