openai_gateway_chat_completions_cancellation_test.go 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "sync"
  10. "testing"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. )
  16. type contextBoundBlockingReadCloser struct {
  17. data []byte
  18. offset int
  19. ctx context.Context
  20. forceClose chan struct{}
  21. closeOnce sync.Once
  22. }
  23. func newContextBoundBlockingReadCloser(data []byte) *contextBoundBlockingReadCloser {
  24. return &contextBoundBlockingReadCloser{
  25. data: data,
  26. forceClose: make(chan struct{}),
  27. }
  28. }
  29. func (r *contextBoundBlockingReadCloser) Read(p []byte) (int, error) {
  30. if r.offset < len(r.data) {
  31. n := copy(p, r.data[r.offset:])
  32. r.offset += n
  33. return n, nil
  34. }
  35. select {
  36. case <-r.ctx.Done():
  37. return 0, r.ctx.Err()
  38. case <-r.forceClose:
  39. return 0, io.EOF
  40. }
  41. }
  42. func (r *contextBoundBlockingReadCloser) Close() error {
  43. select {
  44. case <-r.ctx.Done():
  45. case <-r.forceClose:
  46. }
  47. return nil
  48. }
  49. func (r *contextBoundBlockingReadCloser) forceUnblock() {
  50. r.closeOnce.Do(func() { close(r.forceClose) })
  51. }
  52. type contextBoundHTTPUpstream struct {
  53. body *contextBoundBlockingReadCloser
  54. }
  55. func (u *contextBoundHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  56. u.body.ctx = req.Context()
  57. return &http.Response{
  58. StatusCode: http.StatusOK,
  59. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  60. Body: u.body,
  61. }, nil
  62. }
  63. func (u *contextBoundHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
  64. return u.Do(req, proxyURL, accountID, accountConcurrency)
  65. }
  66. func TestForwardAsChatCompletions_CancelsUpstreamBeforeClosingBody(t *testing.T) {
  67. gin.SetMode(gin.TestMode)
  68. rec := httptest.NewRecorder()
  69. c, _ := gin.CreateTestContext(rec)
  70. body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  71. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body)))
  72. c.Request.Header.Set("Content-Type", "application/json")
  73. upstreamBody := []byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"id\":\"msg_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]}],\"usage\":{\"input_tokens\":17,\"output_tokens\":8,\"total_tokens\":25}}}\n\n")
  74. stream := newContextBoundBlockingReadCloser(upstreamBody)
  75. t.Cleanup(stream.forceUnblock)
  76. cfg := rawChatCompletionsTestConfig()
  77. cfg.Gateway.StreamKeepaliveInterval = 1
  78. svc := &OpenAIGatewayService{
  79. cfg: cfg,
  80. httpUpstream: &contextBoundHTTPUpstream{body: stream},
  81. }
  82. type forwardResult struct {
  83. result *OpenAIForwardResult
  84. err error
  85. }
  86. resultCh := make(chan forwardResult, 1)
  87. go func() {
  88. result, err := svc.ForwardAsChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "", "gpt-5.1")
  89. resultCh <- forwardResult{result: result, err: err}
  90. }()
  91. select {
  92. case got := <-resultCh:
  93. require.NoError(t, got.err)
  94. require.NotNil(t, got.result)
  95. require.Equal(t, 17, got.result.Usage.InputTokens)
  96. case <-time.After(time.Second):
  97. t.Fatal("ForwardAsChatCompletions did not cancel upstream before closing the body")
  98. }
  99. }