openai_images_json_keepalive_test.go 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "time"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. "github.com/tidwall/gjson"
  15. )
  16. func TestOpenAIImagesJSONKeepalive_PreservesValidJSONResponse(t *testing.T) {
  17. gin.SetMode(gin.TestMode)
  18. rec := httptest.NewRecorder()
  19. c, _ := gin.CreateTestContext(rec)
  20. c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
  21. originalWriter := c.Writer
  22. stop := StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond)
  23. waitForOpenAIImagesJSONKeepalive(t, c)
  24. require.Equal(t, -1, OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c))
  25. c.JSON(http.StatusOK, gin.H{"data": []gin.H{{"b64_json": "aW1hZ2U="}}})
  26. stop()
  27. require.Equal(t, http.StatusOK, rec.Code)
  28. require.Equal(t, "application/json; charset=utf-8", rec.Header().Get("Content-Type"))
  29. require.Equal(t, "no", rec.Header().Get("X-Accel-Buffering"))
  30. require.True(t, rec.Flushed)
  31. require.True(t, json.Valid(rec.Body.Bytes()), rec.Body.String())
  32. require.Equal(t, "aW1hZ2U=", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
  33. require.Greater(t, OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c), 0)
  34. require.Same(t, originalWriter, c.Writer)
  35. }
  36. func TestOpenAIImagesJSONKeepalive_DisabledIsNoop(t *testing.T) {
  37. gin.SetMode(gin.TestMode)
  38. rec := httptest.NewRecorder()
  39. c, _ := gin.CreateTestContext(rec)
  40. c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
  41. originalWriter := c.Writer
  42. stop := StartOpenAIImagesJSONKeepalive(c, 0)
  43. stop()
  44. c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "invalid request"}})
  45. require.Same(t, originalWriter, c.Writer)
  46. require.Equal(t, http.StatusBadRequest, rec.Code)
  47. require.Equal(t, "invalid request", gjson.Get(rec.Body.String(), "error.message").String())
  48. }
  49. func TestOpenAIImagesJSONKeepalive_FastErrorPreservesStatus(t *testing.T) {
  50. gin.SetMode(gin.TestMode)
  51. rec := httptest.NewRecorder()
  52. c, _ := gin.CreateTestContext(rec)
  53. c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
  54. stop := StartOpenAIImagesJSONKeepalive(c, time.Second)
  55. wrote := writeOpenAIImagesUpstreamErrorResponse(c, &OpenAIImagesUpstreamError{
  56. StatusCode: http.StatusBadRequest,
  57. ErrorType: "invalid_request_error",
  58. Message: "invalid size",
  59. })
  60. stop()
  61. require.True(t, wrote)
  62. require.Equal(t, http.StatusBadRequest, rec.Code)
  63. require.False(t, strings.HasPrefix(rec.Body.String(), " \n"))
  64. require.Equal(t, "invalid size", gjson.Get(rec.Body.String(), "error.message").String())
  65. }
  66. func TestOpenAIImagesJSONKeepalive_LateErrorRemainsJSON(t *testing.T) {
  67. gin.SetMode(gin.TestMode)
  68. rec := httptest.NewRecorder()
  69. c, _ := gin.CreateTestContext(rec)
  70. c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
  71. stop := StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond)
  72. defer stop()
  73. waitForOpenAIImagesJSONKeepalive(t, c)
  74. wrote := writeOpenAIImagesUpstreamErrorResponse(c, &OpenAIImagesUpstreamError{
  75. StatusCode: http.StatusBadRequest,
  76. ErrorType: "image_generation_user_error",
  77. Code: "moderation_blocked",
  78. Message: "request rejected",
  79. })
  80. require.True(t, wrote)
  81. require.Equal(t, http.StatusOK, rec.Code, "heartbeat already committed the status")
  82. require.True(t, json.Valid(rec.Body.Bytes()), rec.Body.String())
  83. require.Equal(t, "moderation_blocked", gjson.Get(rec.Body.String(), "error.code").String())
  84. require.Equal(t, "request rejected", gjson.Get(rec.Body.String(), "error.message").String())
  85. }
  86. func TestOpenAIImagesJSONKeepalive_DoesNotBlockFailoverDetection(t *testing.T) {
  87. gin.SetMode(gin.TestMode)
  88. rec := httptest.NewRecorder()
  89. c, _ := gin.CreateTestContext(rec)
  90. c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
  91. stop := StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond)
  92. waitForOpenAIImagesJSONKeepalive(t, c)
  93. before := OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c)
  94. require.Equal(t, -1, before)
  95. require.True(t, c.Writer.Written())
  96. require.Equal(t, before, OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c))
  97. stop()
  98. require.True(t, strings.TrimSpace(rec.Body.String()) == "")
  99. }
  100. func TestOpenAIImagesJSONKeepalive_KeepsOAuthNonStreamResponseValid(t *testing.T) {
  101. gin.SetMode(gin.TestMode)
  102. rec := httptest.NewRecorder()
  103. c, _ := gin.CreateTestContext(rec)
  104. c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
  105. reader, writer := io.Pipe()
  106. go func() {
  107. time.Sleep(20 * time.Millisecond)
  108. _, _ = io.WriteString(writer,
  109. "data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000000,\"output\":[{\"type\":\"image_generation_call\",\"result\":\"aW1hZ2U=\",\"output_format\":\"png\"}]}}\n\n"+
  110. "data: [DONE]\n\n",
  111. )
  112. _ = writer.Close()
  113. }()
  114. stop := StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond)
  115. resp := &http.Response{
  116. StatusCode: http.StatusOK,
  117. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  118. Body: reader,
  119. }
  120. svc := &OpenAIGatewayService{}
  121. _, imageCount, _, err := svc.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, "b64_json", "gpt-image-2")
  122. stop()
  123. require.NoError(t, err)
  124. require.Equal(t, 1, imageCount)
  125. require.True(t, rec.Flushed)
  126. require.True(t, strings.HasPrefix(rec.Body.String(), " \n"), rec.Body.String())
  127. require.True(t, json.Valid(rec.Body.Bytes()), rec.Body.String())
  128. require.Equal(t, "aW1hZ2U=", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
  129. }
  130. func TestOpenAIImagesJSONKeepaliveWriter_NilGuards(t *testing.T) {
  131. w := &openAIImagesJSONKeepaliveWriter{}
  132. require.NotPanics(t, func() {
  133. require.NotNil(t, w.Header())
  134. _, _ = w.Write([]byte("test"))
  135. _, _ = w.WriteString("test")
  136. w.WriteHeader(http.StatusOK)
  137. w.WriteHeaderNow()
  138. w.Flush()
  139. require.Equal(t, 0, w.Status())
  140. require.Equal(t, 0, w.Size())
  141. require.False(t, w.Written())
  142. require.Nil(t, w.Pusher())
  143. })
  144. conn, _, err := w.Hijack()
  145. require.Error(t, err)
  146. require.Nil(t, conn)
  147. select {
  148. case <-w.CloseNotify():
  149. default:
  150. t.Fatal("nil writer CloseNotify channel should be closed")
  151. }
  152. }
  153. // 回归:failover 第 2+ 轮时,上一轮心跳残留的空白字节不得被误判为"已写响应",
  154. // 可重试上游错误必须仍转换为 UpstreamFailoverError(而非裸错误吞掉换号)。
  155. func TestOpenAIImagesJSONKeepalive_HeartbeatBeforeForwardStillFailsOver(t *testing.T) {
  156. gin.SetMode(gin.TestMode)
  157. body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
  158. req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
  159. req.Header.Set("Content-Type", "application/json")
  160. rec := httptest.NewRecorder()
  161. c, _ := gin.CreateTestContext(rec)
  162. c.Request = req
  163. svc := &OpenAIGatewayService{
  164. httpUpstream: &httpUpstreamRecorder{
  165. resp: &http.Response{
  166. StatusCode: http.StatusOK,
  167. Header: http.Header{
  168. "Content-Type": []string{"text/event-stream"},
  169. "X-Request-Id": []string{"req_img_heartbeat_failover"},
  170. },
  171. Body: io.NopCloser(strings.NewReader(
  172. "data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000021}}\n\n" +
  173. "data: {\"type\":\"error\",\"error\":{\"type\":\"server_error\",\"code\":\"server_error\",\"message\":\"The image service is temporarily unavailable.\"}}\n\n",
  174. )),
  175. },
  176. },
  177. }
  178. parsed, err := svc.ParseOpenAIImagesRequest(c, body)
  179. require.NoError(t, err)
  180. // 模拟上一轮 failover 已发生:心跳已提交 200 并写出空白字节。
  181. stop := StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond)
  182. defer stop()
  183. waitForOpenAIImagesJSONKeepalive(t, c)
  184. account := &Account{
  185. ID: 22,
  186. Name: "openai-oauth-heartbeat-failover",
  187. Platform: PlatformOpenAI,
  188. Type: AccountTypeOAuth,
  189. Credentials: map[string]any{
  190. "access_token": "token-123",
  191. },
  192. }
  193. result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
  194. require.Nil(t, result)
  195. var failoverErr *UpstreamFailoverError
  196. require.ErrorAs(t, err, &failoverErr)
  197. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  198. require.Contains(t, string(failoverErr.ResponseBody), "temporarily unavailable")
  199. require.Empty(t, strings.TrimSpace(rec.Body.String()), "only heartbeat whitespace may reach the client")
  200. rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
  201. require.True(t, ok)
  202. events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
  203. require.True(t, ok)
  204. require.Len(t, events, 1)
  205. require.Equal(t, "failover", events[0].Kind)
  206. require.Equal(t, account.ID, events[0].AccountID)
  207. require.Equal(t, http.StatusBadGateway, events[0].UpstreamStatusCode)
  208. }
  209. func waitForOpenAIImagesJSONKeepalive(t *testing.T, c *gin.Context) {
  210. t.Helper()
  211. k := openAIImagesJSONKeepaliveFromContext(c)
  212. require.NotNil(t, k)
  213. require.Eventually(t, func() bool {
  214. k.mu.Lock()
  215. defer k.mu.Unlock()
  216. return k.started
  217. }, time.Second, time.Millisecond)
  218. }