openai_images_upstream_context_test.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. )
  14. func newOpenAIImagesTestContext(t *testing.T, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
  15. t.Helper()
  16. gin.SetMode(gin.TestMode)
  17. req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
  18. req.Header.Set("Content-Type", "application/json")
  19. rec := httptest.NewRecorder()
  20. c, _ := gin.CreateTestContext(rec)
  21. c.Request = req
  22. return c, rec
  23. }
  24. func newOpenAIImagesTestService(upstream HTTPUpstream) *OpenAIGatewayService {
  25. return &OpenAIGatewayService{
  26. httpUpstream: upstream,
  27. cfg: &config.Config{
  28. Security: config.SecurityConfig{
  29. URLAllowlist: config.URLAllowlistConfig{Enabled: false},
  30. },
  31. },
  32. }
  33. }
  34. func newOpenAIImagesAPIKeyAccount() *Account {
  35. return &Account{
  36. ID: 31,
  37. Name: "openai-apikey-images",
  38. Platform: PlatformOpenAI,
  39. Type: AccountTypeAPIKey,
  40. Credentials: map[string]any{
  41. "api_key": "sk-test",
  42. "base_url": "https://api.openai.com/v1",
  43. },
  44. }
  45. }
  46. func openAIImagesJSONResponse() *http.Response {
  47. return &http.Response{
  48. StatusCode: http.StatusOK,
  49. Header: http.Header{
  50. "Content-Type": []string{"application/json"},
  51. "X-Request-Id": []string{"req_img_ctx"},
  52. },
  53. Body: io.NopCloser(strings.NewReader(
  54. `{"created":1710000000,"data":[{"b64_json":"aGVsbG8="}],"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}`,
  55. )),
  56. }
  57. }
  58. // issue #5411:生图是长耗时、上游侧已经产生实际成本的操作。客户端中途断开时,
  59. // 如果连带取消上游请求,就会出现「上游已出图并计费、网关记 502 context canceled、
  60. // 用户不扣费」。非流式路径以前走 detachStreamUpstreamContext(ctx, false),
  61. // 该函数在非流式时原样返回请求 context,因此不脱钩。
  62. func TestForwardOpenAIImagesAPIKey_NonStreamDetachesUpstreamContext(t *testing.T) {
  63. body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
  64. c, _ := newOpenAIImagesTestContext(t, body)
  65. recorder := &httpUpstreamRecorder{resp: openAIImagesJSONResponse()}
  66. svc := newOpenAIImagesTestService(recorder)
  67. parsed, err := svc.ParseOpenAIImagesRequest(c, body)
  68. require.NoError(t, err)
  69. require.False(t, parsed.Stream, "本用例覆盖非流式生图")
  70. ctx, cancel := context.WithCancel(context.Background())
  71. cancel() // 客户端已断开
  72. result, err := svc.ForwardImages(ctx, c, newOpenAIImagesAPIKeyAccount(), body, parsed, "")
  73. require.NoError(t, err, "客户端断开不应把已在出图的上游调用打断成 context canceled")
  74. require.NotNil(t, result)
  75. require.Equal(t, 1, result.ImageCount, "图片已产出,必须带回结果供计费")
  76. require.NotNil(t, recorder.lastReq)
  77. require.NoError(t, recorder.lastReq.Context().Err(),
  78. "交给上游的请求 context 必须已脱钩,不随客户端断开取消")
  79. }
  80. // 流式路径本来就脱钩,这条守卫防止对齐时把它改坏。
  81. func TestForwardOpenAIImagesAPIKey_StreamKeepsDetachedUpstreamContext(t *testing.T) {
  82. body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
  83. c, _ := newOpenAIImagesTestContext(t, body)
  84. recorder := &httpUpstreamRecorder{resp: &http.Response{
  85. StatusCode: http.StatusOK,
  86. Header: http.Header{
  87. "Content-Type": []string{"text/event-stream"},
  88. "X-Request-Id": []string{"req_img_ctx_stream"},
  89. },
  90. Body: io.NopCloser(strings.NewReader(
  91. "data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000000}}\n\n" +
  92. "data: {\"type\":\"response.image_generation_call.completed\",\"result\":\"aGVsbG8=\"}\n\n" +
  93. "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":20}}}\n\n",
  94. )),
  95. }}
  96. svc := newOpenAIImagesTestService(recorder)
  97. parsed, err := svc.ParseOpenAIImagesRequest(c, body)
  98. require.NoError(t, err)
  99. require.True(t, parsed.Stream)
  100. ctx, cancel := context.WithCancel(context.Background())
  101. cancel()
  102. _, _ = svc.ForwardImages(ctx, c, newOpenAIImagesAPIKeyAccount(), body, parsed, "")
  103. require.NotNil(t, recorder.lastReq)
  104. require.NoError(t, recorder.lastReq.Context().Err(),
  105. "流式路径原本就脱钩,不能被改回随客户端取消")
  106. }
  107. // 两个 detach 辅助函数的语义差异是本次修复的根据,锁死它们防止被悄悄改动。
  108. func TestDetachUpstreamContextSemantics(t *testing.T) {
  109. t.Run("detachUpstreamContext_always_detaches", func(t *testing.T) {
  110. ctx, cancel := context.WithCancel(context.Background())
  111. cancel()
  112. detached, release := detachUpstreamContext(ctx)
  113. defer release()
  114. require.NoError(t, detached.Err())
  115. })
  116. t.Run("detachStreamUpstreamContext_keeps_cancel_when_not_streaming", func(t *testing.T) {
  117. ctx, cancel := context.WithCancel(context.Background())
  118. cancel()
  119. same, release := detachStreamUpstreamContext(ctx, false)
  120. defer release()
  121. require.ErrorIs(t, same.Err(), context.Canceled,
  122. "非流式时该函数原样返回请求 context —— 生图路径不能用它")
  123. })
  124. t.Run("detachStreamUpstreamContext_detaches_when_streaming", func(t *testing.T) {
  125. ctx, cancel := context.WithCancel(context.Background())
  126. cancel()
  127. detached, release := detachStreamUpstreamContext(ctx, true)
  128. defer release()
  129. require.NoError(t, detached.Err())
  130. })
  131. }