mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
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:
@@ -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 工具未执行)。这是上游的概率性失败——同账号有时成功有时失败。
|
||||
// 处理:① 记录上游诊断摘要到 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,
|
||||
|
||||
Reference in New Issue
Block a user