Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
171 lines
8.1 KiB
Go
171 lines
8.1 KiB
Go
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)")
|
||
}
|
||
}
|
||
|
||
// 内容审核拒绝(模型未出图但输出文字拒绝)应返回 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)
|
||
}
|
||
}
|