From 37238c0985972693a89075c35717e45ec428b77c Mon Sep 17 00:00:00 2001 From: superman2003 <2112076433zcr@gmail.com> Date: Fri, 18 Sep 2026 23:50:54 +0800 Subject: [PATCH] fix(openai): honor error.status when classifying in-stream failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI 兼容上游(含二级中转)常把流内 error / response.failed 事件的状态码写在 error.status 上,而不是 OpenAI 使用的 error.status_code。SSE 路径只读 status_code,于是这些上游的 401/403/429/529 全部被降级成通用 502:认证失败不 再判定为凭据故障、429 不再标记限流与配额窗口、error 帧丢掉 401/429/529 的 failover 快路径,运维日志也记成 502。WS 路径的 openAIWSPayloadTransientStatus 早已同时读这两种拼写,SSE 路径一直没跟上。 把状态码路径集合抽成共享常量,让 SSE 路径与 WS 路径对齐。5xx 仍按通用上游故障 处理,不改动既有的账号状态语义。 --- .../openai_access_state_failover_test.go | 60 +++++++++++++++++++ .../service/openai_gateway_passthrough.go | 17 +++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/openai_access_state_failover_test.go b/backend/internal/service/openai_access_state_failover_test.go index 48c8fb617e..92254d92e5 100644 --- a/backend/internal/service/openai_access_state_failover_test.go +++ b/backend/internal/service/openai_access_state_failover_test.go @@ -226,6 +226,66 @@ func TestOpenAIStreamSemanticStatusesPreservedAcrossTerminalShapes(t *testing.T) } } +// 不少 OpenAI 兼容上游在流内错误对象里用 status 而不是 status_code 报告状态码。 +// 只认 status_code 会把它们降级成通用 502,账号健康与 failover 判定随之失效。 +func TestOpenAIStreamSemanticStatusHonorsErrorStatusAlias(t *testing.T) { + tests := []struct { + name string + body string + status int + wantFailover bool + }{ + { + name: "response.error.status unauthorized", + body: `{"type":"response.failed","response":{"error":{"code":"server_error","status":401,"message":"upstream rejected the key"}}}`, + status: http.StatusUnauthorized, + wantFailover: true, + }, + { + name: "error.status rate limited", + body: `{"type":"error","error":{"code":"server_error","status":429,"message":"upstream is busy"}}`, + status: http.StatusTooManyRequests, + wantFailover: true, + }, + { + name: "error.status overloaded", + body: `{"type":"error","error":{"code":"server_error","status":529,"message":"upstream is overloaded"}}`, + status: 529, + wantFailover: true, + }, + { + name: "response.error.status forbidden without account signal", + body: `{"type":"response.failed","response":{"error":{"code":"server_error","status":403,"message":"request was rejected"}}}`, + status: http.StatusForbidden, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := []byte(tt.body) + message := extractOpenAISSEErrorMessage(payload) + require.Equal(t, tt.status, openAIStreamFailureStatus(payload, message)) + require.Equal(t, tt.wantFailover, openAIStreamErrorEventShouldFailover(payload, message)) + require.Equal(t, tt.wantFailover, openAIStreamFailedEventShouldFailover(payload, message)) + }) + } +} + +func TestOpenAIStreamCredentialFailureHonorsErrorStatusAlias(t *testing.T) { + require.True(t, openAIStreamCredentialAuthFailure([]byte(`{"type":"error","error":{"code":"server_error","status":401,"message":"credential rejected"}}`))) + require.True(t, openAIStreamCredentialAuthFailure([]byte(`{"type":"response.failed","response":{"error":{"code":"server_error","status":401,"message":"credential rejected"}}}`))) + require.False(t, openAIStreamCredentialAuthFailure([]byte(`{"type":"response.failed","response":{"error":{"code":"server_error","status":500,"message":"transient upstream failure"}}}`))) +} + +// 上游把 status 报成 5xx 时仍然是通用上游故障:既不改账号状态,也不冒充认证/限流。 +func TestOpenAIStreamErrorStatusAliasKeepsGeneric5xxUnclassified(t *testing.T) { + payload := []byte(`{"type":"response.failed","response":{"error":{"code":"server_error","status":500,"type":"server_error","message":"rv_shape_invalid: temporary response validation failure after partial output; retry your request if your client can recover partial output."},"id":"resp_0","object":"response","status":"failed"},"sequence_number":80}`) + message := extractOpenAISSEErrorMessage(payload) + + require.Equal(t, http.StatusBadGateway, openAIStreamFailureStatus(payload, message)) + require.False(t, openAIStreamCredentialAuthFailure(payload)) + require.True(t, openAIStreamFailedEventShouldFailover(payload, message)) +} + func TestOpenAIStreamBareErrorUsesSemanticFailover(t *testing.T) { payload := []byte(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down"}}`) require.True(t, openAIStreamErrorEventShouldFailover(payload, "slow down")) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index dd8f390079..3e5a4e94aa 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -1366,6 +1366,19 @@ func sanitizeOpenAICapacityShedErrorCodeForClient(payload []byte) ([]byte, bool) return updated, changed } +// openAIStreamErrorStatusPaths 覆盖流内 error / response.failed 事件里上游状态码的 +// 两种拼写:OpenAI 用 status_code,而不少 OpenAI 兼容上游(含二级中转)只写 status。 +// 只认 status_code 会把 401/403/429/529 一律降级成通用 502,账号健康与 failover +// 判定随之失效。WS 路径的 openAIWSPayloadTransientStatus 早已同时读两种拼写。 +var openAIStreamErrorStatusPaths = []string{ + "response.error.status_code", + "response.error.status", + "error.status_code", + "error.status", + "status_code", + "status", +} + func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int { if isOpenAIContextWindowError(message, payload) { return http.StatusBadRequest @@ -1377,7 +1390,7 @@ func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int { errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String())) } combined := strings.TrimSpace(errType + " " + code + " " + strings.ToLower(strings.TrimSpace(message))) - for _, path := range []string{"response.error.status_code", "error.status_code", "status_code"} { + for _, path := range openAIStreamErrorStatusPaths { if status := int(gjson.GetBytes(payload, path).Int()); status == http.StatusUnauthorized || status == http.StatusForbidden || status == http.StatusTooManyRequests || status == 529 { return status @@ -1425,7 +1438,7 @@ func openAIStreamCredentialAuthFailure(payload []byte) bool { if len(bytes.TrimSpace(payload)) == 0 || !gjson.ValidBytes(payload) { return false } - for _, path := range []string{"response.error.status_code", "error.status_code", "status_code"} { + for _, path := range openAIStreamErrorStatusPaths { if int(gjson.GetBytes(payload, path).Int()) == http.StatusUnauthorized { return true }