| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- package service
- import (
- "bytes"
- "context"
- "encoding/json"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- func TestOpenAIImagesJSONKeepalive_PreservesValidJSONResponse(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
- originalWriter := c.Writer
- stop := StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond)
- waitForOpenAIImagesJSONKeepalive(t, c)
- require.Equal(t, -1, OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c))
- c.JSON(http.StatusOK, gin.H{"data": []gin.H{{"b64_json": "aW1hZ2U="}}})
- stop()
- require.Equal(t, http.StatusOK, rec.Code)
- require.Equal(t, "application/json; charset=utf-8", rec.Header().Get("Content-Type"))
- require.Equal(t, "no", rec.Header().Get("X-Accel-Buffering"))
- require.True(t, rec.Flushed)
- require.True(t, json.Valid(rec.Body.Bytes()), rec.Body.String())
- require.Equal(t, "aW1hZ2U=", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
- require.Greater(t, OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c), 0)
- require.Same(t, originalWriter, c.Writer)
- }
- func TestOpenAIImagesJSONKeepalive_DisabledIsNoop(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
- originalWriter := c.Writer
- stop := StartOpenAIImagesJSONKeepalive(c, 0)
- stop()
- c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "invalid request"}})
- require.Same(t, originalWriter, c.Writer)
- require.Equal(t, http.StatusBadRequest, rec.Code)
- require.Equal(t, "invalid request", gjson.Get(rec.Body.String(), "error.message").String())
- }
- func TestOpenAIImagesJSONKeepalive_FastErrorPreservesStatus(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
- stop := StartOpenAIImagesJSONKeepalive(c, time.Second)
- wrote := writeOpenAIImagesUpstreamErrorResponse(c, &OpenAIImagesUpstreamError{
- StatusCode: http.StatusBadRequest,
- ErrorType: "invalid_request_error",
- Message: "invalid size",
- })
- stop()
- require.True(t, wrote)
- require.Equal(t, http.StatusBadRequest, rec.Code)
- require.False(t, strings.HasPrefix(rec.Body.String(), " \n"))
- require.Equal(t, "invalid size", gjson.Get(rec.Body.String(), "error.message").String())
- }
- func TestOpenAIImagesJSONKeepalive_LateErrorRemainsJSON(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
- stop := StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond)
- defer stop()
- waitForOpenAIImagesJSONKeepalive(t, c)
- wrote := writeOpenAIImagesUpstreamErrorResponse(c, &OpenAIImagesUpstreamError{
- StatusCode: http.StatusBadRequest,
- ErrorType: "image_generation_user_error",
- Code: "moderation_blocked",
- Message: "request rejected",
- })
- require.True(t, wrote)
- require.Equal(t, http.StatusOK, rec.Code, "heartbeat already committed the status")
- require.True(t, json.Valid(rec.Body.Bytes()), rec.Body.String())
- require.Equal(t, "moderation_blocked", gjson.Get(rec.Body.String(), "error.code").String())
- require.Equal(t, "request rejected", gjson.Get(rec.Body.String(), "error.message").String())
- }
- func TestOpenAIImagesJSONKeepalive_DoesNotBlockFailoverDetection(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
- stop := StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond)
- waitForOpenAIImagesJSONKeepalive(t, c)
- before := OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c)
- require.Equal(t, -1, before)
- require.True(t, c.Writer.Written())
- require.Equal(t, before, OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c))
- stop()
- require.True(t, strings.TrimSpace(rec.Body.String()) == "")
- }
- func TestOpenAIImagesJSONKeepalive_KeepsOAuthNonStreamResponseValid(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
- reader, writer := io.Pipe()
- go func() {
- time.Sleep(20 * time.Millisecond)
- _, _ = io.WriteString(writer,
- "data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000000,\"output\":[{\"type\":\"image_generation_call\",\"result\":\"aW1hZ2U=\",\"output_format\":\"png\"}]}}\n\n"+
- "data: [DONE]\n\n",
- )
- _ = writer.Close()
- }()
- stop := StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond)
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: reader,
- }
- svc := &OpenAIGatewayService{}
- _, imageCount, _, err := svc.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, "b64_json", "gpt-image-2")
- stop()
- require.NoError(t, err)
- require.Equal(t, 1, imageCount)
- require.True(t, rec.Flushed)
- require.True(t, strings.HasPrefix(rec.Body.String(), " \n"), rec.Body.String())
- require.True(t, json.Valid(rec.Body.Bytes()), rec.Body.String())
- require.Equal(t, "aW1hZ2U=", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
- }
- func TestOpenAIImagesJSONKeepaliveWriter_NilGuards(t *testing.T) {
- w := &openAIImagesJSONKeepaliveWriter{}
- require.NotPanics(t, func() {
- require.NotNil(t, w.Header())
- _, _ = w.Write([]byte("test"))
- _, _ = w.WriteString("test")
- w.WriteHeader(http.StatusOK)
- w.WriteHeaderNow()
- w.Flush()
- require.Equal(t, 0, w.Status())
- require.Equal(t, 0, w.Size())
- require.False(t, w.Written())
- require.Nil(t, w.Pusher())
- })
- conn, _, err := w.Hijack()
- require.Error(t, err)
- require.Nil(t, conn)
- select {
- case <-w.CloseNotify():
- default:
- t.Fatal("nil writer CloseNotify channel should be closed")
- }
- }
- // 回归:failover 第 2+ 轮时,上一轮心跳残留的空白字节不得被误判为"已写响应",
- // 可重试上游错误必须仍转换为 UpstreamFailoverError(而非裸错误吞掉换号)。
- func TestOpenAIImagesJSONKeepalive_HeartbeatBeforeForwardStillFailsOver(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
- req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = req
- svc := &OpenAIGatewayService{
- httpUpstream: &httpUpstreamRecorder{
- resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{
- "Content-Type": []string{"text/event-stream"},
- "X-Request-Id": []string{"req_img_heartbeat_failover"},
- },
- Body: io.NopCloser(strings.NewReader(
- "data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000021}}\n\n" +
- "data: {\"type\":\"error\",\"error\":{\"type\":\"server_error\",\"code\":\"server_error\",\"message\":\"The image service is temporarily unavailable.\"}}\n\n",
- )),
- },
- },
- }
- parsed, err := svc.ParseOpenAIImagesRequest(c, body)
- require.NoError(t, err)
- // 模拟上一轮 failover 已发生:心跳已提交 200 并写出空白字节。
- stop := StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond)
- defer stop()
- waitForOpenAIImagesJSONKeepalive(t, c)
- account := &Account{
- ID: 22,
- Name: "openai-oauth-heartbeat-failover",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "token-123",
- },
- }
- result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
- require.Nil(t, result)
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
- require.Contains(t, string(failoverErr.ResponseBody), "temporarily unavailable")
- require.Empty(t, strings.TrimSpace(rec.Body.String()), "only heartbeat whitespace may reach the client")
- rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
- require.True(t, ok)
- events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
- require.True(t, ok)
- require.Len(t, events, 1)
- require.Equal(t, "failover", events[0].Kind)
- require.Equal(t, account.ID, events[0].AccountID)
- require.Equal(t, http.StatusBadGateway, events[0].UpstreamStatusCode)
- }
- func waitForOpenAIImagesJSONKeepalive(t *testing.T, c *gin.Context) {
- t.Helper()
- k := openAIImagesJSONKeepaliveFromContext(c)
- require.NotNil(t, k)
- require.Eventually(t, func() bool {
- k.mu.Lock()
- defer k.mu.Unlock()
- return k.started
- }, time.Second, time.Millisecond)
- }
|