fix(images): pass content-moderation refusals through as 400 instead of retrying

When gpt-image upstream returns response.completed with no image but a text
refusal (content moderation, e.g. the model replies "this request was judged
unsafe to generate"), the soft-failure path treated it as a probabilistic
upstream failure and returned a retryable UpstreamFailoverError (502). Retrying
or switching accounts is futile for a content-policy block — it just burns
other accounts' quota and still surfaces an opaque 502 to the client.

Distinguish the two no-image cases:
  (A) model text refusal  -> 400 content_policy_violation, no retry, refusal
      reason passed through to the client.
  (B) truly empty response -> unchanged: retryable UpstreamFailoverError (502).

Adds extractOpenAIImagesModelRefusal (extracts the refusal text from
output_text.delta / message output_text, capped at 600 chars) and two unit
tests; the existing empty-response retry test is unaffected.
This commit is contained in:
404QAQ
2026-06-23 16:57:16 +08:00
parent 6936687870
commit 9491de0a39
2 changed files with 128 additions and 8 deletions
@@ -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)
}
}
@@ -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 工具未执行)。这是上游的概率性失败——同账号有时成功有时失败。
// 处理:① 记录上游诊断摘要到 opslast_event/status/model/body 片段)便于排查;
// ② 返回 UpstreamFailoverError 触发重试。因实测为「同账号概率性失败」,优先
// RetryableOnSameAccount 同账号快速重试(默认 3 次,大概率某次正常出图)
// 用尽后由 handler 自然换账号 failoverswitchCount 上限保护),既提高成功率
// 又不无谓消耗其他账号配额。
// 软失败兜底:上游无图。先区分两种情形(实测真因,见下):
//
// (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) 真空响应:记录上游诊断摘要到 opslast_event/status/model/body 片段)便于
// 排查,并返回 UpstreamFailoverError 触发重试。因实测为「同账号概率性失败」,优先
// RetryableOnSameAccount 同账号快速重试(默认 3 次,大概率某次正常出图),用尽后
// 由 handler 自然换账号 failoverswitchCount 上限保护),既提高成功率又不无谓
// 消耗其它账号配额。
setOpsUpstreamError(c, http.StatusBadGateway, "upstream did not return image output", summarizeOpenAIImagesNoOutputBody(body))
return OpenAIUsage{}, 0, nil, &UpstreamFailoverError{
StatusCode: http.StatusBadGateway,