openai_ws_forwarder_client_cancel_test.go 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "sync"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. )
  15. // TestForwardOpenAIWSV2_ClientCancellationDrainsWithoutSyntheticFailure
  16. // reproduces the HTTP/SSE ingress bug: the client cancels after partial output,
  17. // while the upstream WS still has a terminal event available for usage billing.
  18. func TestForwardOpenAIWSV2_ClientCancellationDrainsWithoutSyntheticFailure(t *testing.T) {
  19. gin.SetMode(gin.TestMode)
  20. ctx, cancel := context.WithCancel(context.Background())
  21. defer cancel()
  22. writer := &cancelOnFirstWriteResponseWriter{cancel: cancel}
  23. c, _ := gin.CreateTestContext(writer)
  24. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil).WithContext(ctx)
  25. c.Request.Header.Set("User-Agent", "unit-test-agent/1.0")
  26. captureConn := &openAIWSCancelSafeConn{openAIWSCaptureConn: &openAIWSCaptureConn{
  27. events: [][]byte{
  28. []byte(`{"type":"response.created","response":{"id":"resp_cancel_1","model":"gpt-5.5"}}`),
  29. []byte(`{"type":"response.output_text.delta","delta":"partial"}`),
  30. []byte(`{"type":"response.completed","response":{"id":"resp_cancel_1","model":"gpt-5.5","usage":{"input_tokens":3,"output_tokens":5}}}`),
  31. },
  32. readDelays: []time.Duration{0, 0, 50 * time.Millisecond},
  33. }}
  34. cfg := &config.Config{}
  35. cfg.Security.URLAllowlist.Enabled = false
  36. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  37. cfg.Gateway.OpenAIWS.Enabled = true
  38. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  39. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  40. cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
  41. cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool
  42. cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
  43. cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
  44. cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
  45. cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
  46. cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
  47. cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5
  48. cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
  49. pool := newOpenAIWSConnPool(cfg)
  50. pool.setClientDialerForTest(&openAIWSClientConnCancelDialer{conn: captureConn})
  51. svc := &OpenAIGatewayService{
  52. cfg: cfg,
  53. httpUpstream: &httpUpstreamRecorder{},
  54. cache: &stubGatewayCache{},
  55. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  56. toolCorrector: NewCodexToolCorrector(),
  57. openaiWSPool: pool,
  58. }
  59. account := &Account{
  60. ID: 9101,
  61. Name: "openai-ws-client-cancel",
  62. Platform: PlatformOpenAI,
  63. Type: AccountTypeAPIKey,
  64. Status: StatusActive,
  65. Schedulable: true,
  66. Concurrency: 1,
  67. Credentials: map[string]any{"api_key": "sk-test"},
  68. Extra: map[string]any{
  69. "openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModeCtxPool,
  70. },
  71. }
  72. result, err := svc.Forward(ctx, c, account, []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"input_text","text":"hello"}]}`))
  73. require.NoError(t, err, "client cancellation must not surface as an upstream failure")
  74. require.NotNil(t, result)
  75. require.True(t, result.ClientDisconnect)
  76. require.Equal(t, "resp_cancel_1", result.RequestID)
  77. require.Equal(t, 3, result.Usage.InputTokens)
  78. require.Equal(t, 5, result.Usage.OutputTokens)
  79. require.NotContains(t, writer.body.String(), "response.failed")
  80. }
  81. type cancelOnFirstWriteResponseWriter struct {
  82. mu sync.Mutex
  83. header http.Header
  84. body bytes.Buffer
  85. cancel context.CancelFunc
  86. writes int
  87. status int
  88. }
  89. func (w *cancelOnFirstWriteResponseWriter) Header() http.Header {
  90. if w.header == nil {
  91. w.header = make(http.Header)
  92. }
  93. return w.header
  94. }
  95. func (w *cancelOnFirstWriteResponseWriter) WriteHeader(status int) {
  96. w.status = status
  97. }
  98. func (w *cancelOnFirstWriteResponseWriter) Write(p []byte) (int, error) {
  99. w.mu.Lock()
  100. defer w.mu.Unlock()
  101. if w.writes == 0 && w.cancel != nil {
  102. w.cancel()
  103. }
  104. w.writes++
  105. return w.body.Write(p)
  106. }
  107. func (w *cancelOnFirstWriteResponseWriter) Flush() {}
  108. type openAIWSClientConnCancelDialer struct {
  109. conn openAIWSClientConn
  110. }
  111. func (d *openAIWSClientConnCancelDialer) Dial(
  112. ctx context.Context,
  113. wsURL string,
  114. headers http.Header,
  115. proxyURL string,
  116. ) (openAIWSClientConn, int, http.Header, error) {
  117. return d.conn, 0, nil, nil
  118. }
  119. // openAIWSCancelSafeConn leaves a delayed event queued when its read context is
  120. // canceled, matching websocket frame semantics and allowing the drain read to
  121. // consume that event with a detached context.
  122. type openAIWSCancelSafeConn struct {
  123. *openAIWSCaptureConn
  124. }
  125. func (c *openAIWSCancelSafeConn) ReadMessage(ctx context.Context) ([]byte, error) {
  126. if ctx == nil {
  127. ctx = context.Background()
  128. }
  129. c.mu.Lock()
  130. if c.closed {
  131. c.mu.Unlock()
  132. return nil, errOpenAIWSConnClosed
  133. }
  134. if len(c.events) == 0 {
  135. c.mu.Unlock()
  136. return nil, io.EOF
  137. }
  138. delay := time.Duration(0)
  139. if len(c.readDelays) > 0 {
  140. delay = c.readDelays[0]
  141. }
  142. event := c.events[0]
  143. c.mu.Unlock()
  144. if delay > 0 {
  145. timer := time.NewTimer(delay)
  146. defer timer.Stop()
  147. select {
  148. case <-ctx.Done():
  149. return nil, ctx.Err()
  150. case <-timer.C:
  151. }
  152. }
  153. c.mu.Lock()
  154. defer c.mu.Unlock()
  155. if c.closed {
  156. return nil, errOpenAIWSConnClosed
  157. }
  158. if len(c.events) == 0 {
  159. return nil, io.EOF
  160. }
  161. c.events = c.events[1:]
  162. if len(c.readDelays) > 0 {
  163. c.readDelays = c.readDelays[1:]
  164. }
  165. return event, nil
  166. }