From b0d5592ae207e14760a3168fd43f020fdd289483 Mon Sep 17 00:00:00 2001 From: 404QAQ <91465187+404QAQ@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:25:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(images):=20=E8=AF=86=E5=88=AB=20response.in?= =?UTF-8?q?complete=20+=20=E8=AE=B0=E5=BD=95=E8=BD=AF=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 gpt-image-2 大图/编辑请求 502 报错(社区 issue #2232/#3135/#2516, 现象:upstream did not return image output)。根因是上游生成超时/截断时返回 response.incomplete,旧逻辑只认 error/response.failed,导致: 1) 软失败报成模糊 502 且不触发 failover 换账号重试 2) 上游真实响应未记录,ops_error_logs 里上游信息全空,无法排查 - openAIImagesUpstreamErrorFromSSEPayload 增加 response.incomplete 识别: 生成超时/截断(max_output_tokens 等) → 可重试 502 触发 failover; content_filter/moderation → 400 不重试 - 软失败兜底(无图无标准错误)记录上游诊断摘要到 ops(last_event/status/ incomplete_reason/body 片段),非流式+流式两条路径一致 - summarizeOpenAIImagesNoOutputBody 提取诊断信息,body 截断上限 1KB 基于官方 v0.1.137 净分支。测试: 5 个新单测 + 图片回归通过 (-tags unit) --- .../service/openai_images_incomplete_test.go | 123 ++++++++++++++++++ .../service/openai_images_responses.go | 96 +++++++++++++- 2 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/openai_images_incomplete_test.go diff --git a/backend/internal/service/openai_images_incomplete_test.go b/backend/internal/service/openai_images_incomplete_test.go new file mode 100644 index 0000000000..60e4412864 --- /dev/null +++ b/backend/internal/service/openai_images_incomplete_test.go @@ -0,0 +1,123 @@ +package service + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +// response.incomplete(生成超时/截断)应被识别为可重试的 502 上游错误,触发 failover。 +func TestExtractImagesUpstreamError_IncompleteIsRetryable(t *testing.T) { + body := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n" + + "data: {\"type\":\"response.incomplete\",\"response\":{\"id\":\"resp_1\",\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n" + got := extractOpenAIImagesUpstreamError([]byte(body)) + if got == nil { + t.Fatal("incomplete event should produce an upstream error, got nil") + } + if got.StatusCode != http.StatusBadGateway { + t.Fatalf("incomplete(max_output_tokens) should be 502 retryable, got %d", got.StatusCode) + } + if !IsOpenAIImagesRetryableUpstreamError(got) { + t.Fatal("incomplete(max_output_tokens) should be retryable for failover") + } + if got.Code != "response_incomplete" { + t.Fatalf("unexpected code %q", got.Code) + } + if !strings.Contains(got.Message, "max_output_tokens") { + t.Fatalf("message should carry reason, got %q", got.Message) + } +} + +// incomplete 因 content_filter → 400,重试无意义,不应触发 failover。 +func TestExtractImagesUpstreamError_IncompleteContentFilterNotRetryable(t *testing.T) { + body := "data: {\"type\":\"response.incomplete\",\"response\":{\"id\":\"r\",\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"content_filter\"}}}\n\n" + got := extractOpenAIImagesUpstreamError([]byte(body)) + if got == nil { + t.Fatal("content_filter incomplete should produce error") + } + if got.StatusCode != http.StatusBadRequest { + t.Fatalf("content_filter should be 400 (non-retryable), got %d", got.StatusCode) + } + if IsOpenAIImagesRetryableUpstreamError(got) { + t.Fatal("content_filter must NOT be retryable") + } +} + +// 旧行为不变:error / response.failed 仍按原逻辑识别。 +func TestExtractImagesUpstreamError_ErrorAndFailedUnchanged(t *testing.T) { + errBody := "data: {\"type\":\"error\",\"error\":{\"type\":\"image_generation_user_error\",\"code\":\"moderation_blocked\",\"message\":\"rejected\"}}\n\n" + if got := extractOpenAIImagesUpstreamError([]byte(errBody)); got == nil || got.StatusCode != http.StatusBadRequest { + t.Fatalf("moderation_blocked should still be 400, got %+v", got) + } +} + +// 上游既无图、又无任何可识别事件时,摘要函数应提取诊断信息。 +func TestSummarizeNoOutputBody_ExtractsDiagnostics(t *testing.T) { + body := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r\"}}\n\n" + + "data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"r\",\"status\":\"in_progress\"}}\n\n" + summary := summarizeOpenAIImagesNoOutputBody([]byte(body)) + if !strings.HasPrefix(summary, "no_image_output") { + t.Fatalf("summary should start with marker, got %q", summary) + } + if !strings.Contains(summary, "last_event=response.in_progress") { + t.Fatalf("summary should capture last event type, got %q", summary) + } + if !strings.Contains(summary, "status=in_progress") { + t.Fatalf("summary should capture response status, got %q", summary) + } +} + +// 摘要应能抓到 incomplete_reason 并对超长 body 截断。 +func TestSummarizeNoOutputBody_IncompleteReasonAndTruncation(t *testing.T) { + long := strings.Repeat("x", 2000) + body := "data: {\"type\":\"response.incomplete\",\"response\":{\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"},\"junk\":\"" + long + "\"}}\n\n" + summary := summarizeOpenAIImagesNoOutputBody([]byte(body)) + if !strings.Contains(summary, "incomplete_reason=max_output_tokens") { + t.Fatalf("should capture incomplete reason, got %q", summary[:120]) + } + if !strings.Contains(summary, "truncated") { + t.Fatalf("oversized body should be truncated, len=%d", len(summary)) + } +} + +// 软失败(上游 completed 但无图,如偶发路由到 mini 模型)应返回可重试的 +// UpstreamFailoverError 且优先同账号重试,而非一次性失败。 +func TestImagesOAuthNonStreaming_CompletedNoImageTriggersSameAccountRetry(t *testing.T) { + // 上游 SSE:response.completed 但 output 为空(实测的真实失败形态)。 + upstreamSSE := "event: response.created\n" + + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_x\",\"status\":\"in_progress\",\"model\":\"gpt-5.4-mini-2026-03-17\",\"output\":[]}}\n\n" + + "event: response.completed\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_x\",\"status\":\"completed\",\"model\":\"gpt-5.4-mini-2026-03-17\",\"output\":[],\"tool_usage\":{\"image_gen\":{\"output_tokens\":0}}}}\n\n" + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil) + + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader(upstreamSSE)), + } + + svc := &OpenAIGatewayService{} + _, _, _, err := svc.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, "b64_json", "gpt-image-2") + + if err == nil { + t.Fatal("completed-but-no-image should return an error") + } + var failoverErr *UpstreamFailoverError + if !errors.As(err, &failoverErr) { + t.Fatalf("expected *UpstreamFailoverError to trigger retry, got %T: %v", err, err) + } + if failoverErr.StatusCode != http.StatusBadGateway { + t.Fatalf("expected 502, got %d", failoverErr.StatusCode) + } + if !failoverErr.RetryableOnSameAccount { + t.Fatal("soft-failure should prefer same-account retry (probabilistic upstream failure)") + } +} diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 482b527448..102e504ca8 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -597,11 +597,89 @@ func openAIImagesUpstreamErrorFromSSEPayload(payload []byte) *OpenAIImagesUpstre case "response.failed": response := gjson.GetBytes(payload, "response") return openAIImagesUpstreamErrorFromGJSON(response.Get("error"), response.Get("id").String()) + case "response.incomplete": + // 上游在生成预算内未产出图片(超时/被截断),返回 response.incomplete 而非 error。 + // 旧逻辑识别不到,统一报成模糊的 "upstream did not return image output" + 502, + // 且不触发 failover。这里把它显式建模为可重试的上游错误,使其能换账号重试。 + return openAIImagesIncompleteUpstreamError(gjson.GetBytes(payload, "response")) default: return nil } } +// summarizeOpenAIImagesNoOutputBody 从上游 SSE 响应体提取诊断摘要,用于软失败时 +// 记录到 ops 日志(上游无图、无标准错误的场景)。提取最终事件类型、response.status、 +// incomplete_details.reason,并附 body 截断片段,便于事后定位上游到底返回了什么。 +func summarizeOpenAIImagesNoOutputBody(body []byte) string { + var lastType, status, incompleteReason string + forEachOpenAISSEDataPayload(string(body), func(payload []byte) { + if !gjson.ValidBytes(payload) { + return + } + if t := strings.TrimSpace(gjson.GetBytes(payload, "type").String()); t != "" { + lastType = t + } + if resp := gjson.GetBytes(payload, "response"); resp.Exists() { + if s := strings.TrimSpace(resp.Get("status").String()); s != "" { + status = s + } + if r := strings.TrimSpace(resp.Get("incomplete_details.reason").String()); r != "" { + incompleteReason = r + } + } + }) + var b strings.Builder + b.WriteString("no_image_output") + if lastType != "" { + fmt.Fprintf(&b, " last_event=%s", lastType) + } + if status != "" { + fmt.Fprintf(&b, " status=%s", status) + } + if incompleteReason != "" { + fmt.Fprintf(&b, " incomplete_reason=%s", incompleteReason) + } + // 附 body 截断片段(脱敏后),上限 1KB,避免日志膨胀。 + snippet := strings.TrimSpace(string(body)) + const maxSnippet = 1024 + if len(snippet) > maxSnippet { + snippet = snippet[:maxSnippet] + "...(truncated)" + } + if snippet != "" { + fmt.Fprintf(&b, " body=%s", snippet) + } + return b.String() +} + +// openAIImagesIncompleteUpstreamError 从 response.incomplete 事件构建可重试的上游错误。 +// incomplete_details.reason 常见取值:max_output_tokens / content_filter 等。 +// content_filter 视为客户端错误(400,重试无意义);其余(生成超时/截断)视为 +// 可重试的 502,触发 failover 换账号重试。 +func openAIImagesIncompleteUpstreamError(response gjson.Result) *OpenAIImagesUpstreamError { + if !response.Exists() { + return nil + } + reason := strings.TrimSpace(response.Get("incomplete_details.reason").String()) + statusCode := http.StatusBadGateway // 默认可重试(生成未完成) + errType := "incomplete_error" + if strings.Contains(strings.ToLower(reason), "content_filter") || + strings.Contains(strings.ToLower(reason), "moderation") { + statusCode = http.StatusBadRequest // 内容过滤,重试无意义 + errType = "image_generation_user_error" + } + message := "Upstream did not complete image generation" + if reason != "" { + message = fmt.Sprintf("Upstream image generation incomplete: %s", reason) + } + return &OpenAIImagesUpstreamError{ + StatusCode: statusCode, + ErrorType: errType, + Code: "response_incomplete", + Message: sanitizeUpstreamErrorMessage(message), + UpstreamRequestID: strings.TrimSpace(response.Get("id").String()), + } +} + func openAIImagesUpstreamErrorFromGJSON(errorObj gjson.Result, upstreamRequestID string) *OpenAIImagesUpstreamError { if !errorObj.Exists() { return nil @@ -959,7 +1037,20 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse( } return OpenAIUsage{}, 0, nil, upstreamErr } - return OpenAIUsage{}, 0, nil, fmt.Errorf("upstream did not return image output") + // 软失败兜底:上游既无图、又无任何可识别的 error/failed/incomplete 事件 + // (实测:上游偶发把请求路由到 gpt-5.x-mini,返回 response.completed 但 output 为空、 + // image_gen 工具未执行)。这是上游的概率性失败——同账号有时成功有时失败。 + // 处理:① 记录上游诊断摘要到 ops(last_event/status/model/body 片段)便于排查; + // ② 返回 UpstreamFailoverError 触发重试。因实测为「同账号概率性失败」,优先 + // RetryableOnSameAccount 同账号快速重试(默认 3 次,大概率某次正常出图), + // 用尽后由 handler 自然换账号 failover(switchCount 上限保护),既提高成功率 + // 又不无谓消耗其他账号配额。 + setOpsUpstreamError(c, http.StatusBadGateway, "upstream did not return image output", summarizeOpenAIImagesNoOutputBody(body)) + return OpenAIUsage{}, 0, nil, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: body, + RetryableOnSameAccount: true, + } } if strings.TrimSpace(firstMeta.Model) == "" { firstMeta.Model = strings.TrimSpace(fallbackModel) @@ -1096,6 +1187,9 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( } if len(finalResults) == 0 { outputErr := fmt.Errorf("upstream did not return image output") + // 软失败:response.completed 事件里没有图片。记录上游诊断摘要到 ops, + // 与非流式路径保持一致,避免上游响应信息丢失。 + setOpsUpstreamError(c, http.StatusBadGateway, "upstream did not return image output", summarizeOpenAIImagesNoOutputBody(dataBytes)) s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(outputErr.Error())) processDataErr = outputErr processDataDone = true