| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116 |
- //go:build unit
- package service
- import (
- "context"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- type contextBoundBlockingReadCloser struct {
- data []byte
- offset int
- ctx context.Context
- forceClose chan struct{}
- closeOnce sync.Once
- }
- func newContextBoundBlockingReadCloser(data []byte) *contextBoundBlockingReadCloser {
- return &contextBoundBlockingReadCloser{
- data: data,
- forceClose: make(chan struct{}),
- }
- }
- func (r *contextBoundBlockingReadCloser) Read(p []byte) (int, error) {
- if r.offset < len(r.data) {
- n := copy(p, r.data[r.offset:])
- r.offset += n
- return n, nil
- }
- select {
- case <-r.ctx.Done():
- return 0, r.ctx.Err()
- case <-r.forceClose:
- return 0, io.EOF
- }
- }
- func (r *contextBoundBlockingReadCloser) Close() error {
- select {
- case <-r.ctx.Done():
- case <-r.forceClose:
- }
- return nil
- }
- func (r *contextBoundBlockingReadCloser) forceUnblock() {
- r.closeOnce.Do(func() { close(r.forceClose) })
- }
- type contextBoundHTTPUpstream struct {
- body *contextBoundBlockingReadCloser
- }
- func (u *contextBoundHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- u.body.ctx = req.Context()
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: u.body,
- }, nil
- }
- func (u *contextBoundHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
- return u.Do(req, proxyURL, accountID, accountConcurrency)
- }
- func TestForwardAsChatCompletions_CancelsUpstreamBeforeClosingBody(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body)))
- c.Request.Header.Set("Content-Type", "application/json")
- 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")
- stream := newContextBoundBlockingReadCloser(upstreamBody)
- t.Cleanup(stream.forceUnblock)
- cfg := rawChatCompletionsTestConfig()
- cfg.Gateway.StreamKeepaliveInterval = 1
- svc := &OpenAIGatewayService{
- cfg: cfg,
- httpUpstream: &contextBoundHTTPUpstream{body: stream},
- }
- type forwardResult struct {
- result *OpenAIForwardResult
- err error
- }
- resultCh := make(chan forwardResult, 1)
- go func() {
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "", "gpt-5.1")
- resultCh <- forwardResult{result: result, err: err}
- }()
- select {
- case got := <-resultCh:
- require.NoError(t, got.err)
- require.NotNil(t, got.result)
- require.Equal(t, 17, got.result.Usage.InputTokens)
- case <-time.After(time.Second):
- t.Fatal("ForwardAsChatCompletions did not cancel upstream before closing the body")
- }
- }
|