diff --git a/backend/internal/service/openai_images_incomplete_test.go b/backend/internal/service/openai_images_incomplete_test.go index 60e4412864..6a8ba56225 100644 --- a/backend/internal/service/openai_images_incomplete_test.go +++ b/backend/internal/service/openai_images_incomplete_test.go @@ -121,3 +121,50 @@ func TestImagesOAuthNonStreaming_CompletedNoImageTriggersSameAccountRetry(t *tes t.Fatal("soft-failure should prefer same-account retry (probabilistic upstream failure)") } } + +// 内容审核拒绝(模型未出图但输出文字拒绝)应返回 400 content_policy 错误且不重试, +// 而非可重试的 UpstreamFailoverError。 +func TestImagesOAuthNonStreaming_ContentRefusalReturns400NoRetry(t *testing.T) { + // 上游:completed 无图,但模型输出了文字拒绝(内容审核场景)。 + upstreamSSE := "event: response.created\n" + + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r\",\"status\":\"in_progress\",\"model\":\"gpt-5.4-mini\",\"output\":[]}}\n\n" + + "event: response.output_text.delta\n" + + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"抱歉,这个请求因涉及违规内容被安全系统判定为不适合生成。\"}\n\n" + + "event: response.completed\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r\",\"status\":\"completed\",\"model\":\"gpt-5.4-mini\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"抱歉,这个请求因涉及违规内容被安全系统判定为不适合生成。\"}]}],\"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("content refusal should return an error") + } + // 应是不可重试的内容策略错误(400),而非 UpstreamFailoverError。 + var failoverErr *UpstreamFailoverError + if errors.As(err, &failoverErr) { + t.Fatalf("content refusal must NOT be a retryable failover error, got %v", failoverErr) + } + var imgErr *OpenAIImagesUpstreamError + if !errors.As(err, &imgErr) { + t.Fatalf("expected *OpenAIImagesUpstreamError, got %T: %v", err, err) + } + if imgErr.StatusCode != http.StatusBadRequest { + t.Fatalf("content refusal should be 400, got %d", imgErr.StatusCode) + } + if !strings.Contains(imgErr.Message, "安全系统") && !strings.Contains(imgErr.Message, "违规") { + t.Fatalf("refusal message should carry model's reason, got %q", imgErr.Message) + } +} + +// extractOpenAIImagesModelRefusal:真空响应(无文字)返回空串。 +func TestExtractModelRefusal_EmptyWhenNoText(t *testing.T) { + body := "data: {\"type\":\"response.completed\",\"response\":{\"output\":[],\"tool_usage\":{\"image_gen\":{\"output_tokens\":0}}}}\n\n" + if r := extractOpenAIImagesModelRefusal([]byte(body)); r != "" { + t.Fatalf("empty response should yield no refusal, got %q", r) + } +} diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index c98e8008c1..226cbf8e88 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -607,6 +607,62 @@ func openAIImagesUpstreamErrorFromSSEPayload(payload []byte) *OpenAIImagesUpstre } } +// extractOpenAIImagesModelRefusal 从上游 SSE 响应体提取「模型未出图、改用文字拒绝」 +// 的拒绝文本(内容审核场景)。 +// +// 上游 response.completed 无图时,模型常以 output_text / message 形式输出拒绝说明 +// (如“被安全系统判定为不适合生成”)。这类失败是内容策略拦截,重试/换账号均无效, +// 应把该文本作为内容策略错误透传给客户端。返回空串表示无文字输出(真空响应)。 +func extractOpenAIImagesModelRefusal(body []byte) string { + var b strings.Builder + collect := func(s string) { + if s = strings.TrimSpace(s); s != "" { + if b.Len() > 0 { + _ = b.WriteByte(' ') + } + _, _ = b.WriteString(s) + } + } + forEachOpenAISSEDataPayload(string(body), func(payload []byte) { + if !gjson.ValidBytes(payload) { + return + } + switch gjson.GetBytes(payload, "type").String() { + case "response.output_text.delta": + // 流式文本增量。 + collect(gjson.GetBytes(payload, "delta").String()) + case "response.completed", "response.output_item.done": + // 终态里的 message/output_text。 + gjson.GetBytes(payload, "response.output").ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "message" { + item.Get("content").ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "output_text" { + collect(part.Get("text").String()) + } + return true + }) + } + return true + }) + if item := gjson.GetBytes(payload, "item"); item.Get("type").String() == "message" { + item.Get("content").ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "output_text" { + collect(part.Get("text").String()) + } + return true + }) + } + } + }) + refusal := strings.TrimSpace(b.String()) + // 截断过长文本,避免把整段模型输出塞进错误响应。 + const maxRefusal = 600 + if len(refusal) > maxRefusal { + refusal = refusal[:maxRefusal] + } + return refusal +} + // summarizeOpenAIImagesNoOutputBody 从上游 SSE 响应体提取诊断摘要,用于软失败时 // 记录到 ops 日志(上游无图、无标准错误的场景)。提取最终事件类型、response.status、 // incomplete_details.reason,并附 body 截断片段,便于事后定位上游到底返回了什么。 @@ -1037,14 +1093,31 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse( } return OpenAIUsage{}, 0, nil, upstreamErr } - // 软失败兜底:上游既无图、又无任何可识别的 error/failed/incomplete 事件 - // (实测:上游偶发把请求路由到 gpt-5.x-mini,返回 response.completed 但 output 为空、 - // image_gen 工具未执行)。这是上游的概率性失败——同账号有时成功有时失败。 - // 处理:① 记录上游诊断摘要到 ops(last_event/status/model/body 片段)便于排查; - // ② 返回 UpstreamFailoverError 触发重试。因实测为「同账号概率性失败」,优先 - // RetryableOnSameAccount 同账号快速重试(默认 3 次,大概率某次正常出图), - // 用尽后由 handler 自然换账号 failover(switchCount 上限保护),既提高成功率 - // 又不无谓消耗其他账号配额。 + // 软失败兜底:上游无图。先区分两种情形(实测真因,见下): + // + // (A) 内容审核拒绝:模型未出图,但输出了文字拒绝(response.completed 里带 + // output_text / message,内容如“被安全系统判定为不适合生成”)。这是用户 + // prompt 触发 OpenAI 内容策略,模型主动拒绝改用文字回应。**换账号/重试均无效** + // (内容层拦截,与账号/承载模型无关),应把拒绝理由作为 400 透传给客户端, + // 避免无谓地重试 + 消耗其它账号配额,且让客户端拿到可读的拒绝原因。 + // (B) 真空响应:既无图也无任何文字输出(罕见,如偶发路由到 gpt-5.x-mini、 + // image_gen 工具未执行)。这是上游的概率性失败,此时才按可重试处理。 + if refusal := extractOpenAIImagesModelRefusal(body); refusal != "" { + refusalErr := &OpenAIImagesUpstreamError{ + StatusCode: http.StatusBadRequest, + ErrorType: "image_generation_user_error", + Code: "content_policy_violation", + Message: sanitizeUpstreamErrorMessage(refusal), + } + setOpsUpstreamError(c, http.StatusBadRequest, refusalErr.clientMessage(), summarizeOpenAIImagesNoOutputBody(body)) + writeOpenAIImagesUpstreamErrorResponse(c, refusalErr) + return OpenAIUsage{}, 0, nil, refusalErr + } + // (B) 真空响应:记录上游诊断摘要到 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,