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 f421a3f6bd..4ff19f6781 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 }