| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184 |
- package service
- import (
- "bytes"
- "context"
- "io"
- "net/http"
- "net/http/httptest"
- "sync"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- // TestForwardOpenAIWSV2_ClientCancellationDrainsWithoutSyntheticFailure
- // reproduces the HTTP/SSE ingress bug: the client cancels after partial output,
- // while the upstream WS still has a terminal event available for usage billing.
- func TestForwardOpenAIWSV2_ClientCancellationDrainsWithoutSyntheticFailure(t *testing.T) {
- gin.SetMode(gin.TestMode)
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- writer := &cancelOnFirstWriteResponseWriter{cancel: cancel}
- c, _ := gin.CreateTestContext(writer)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil).WithContext(ctx)
- c.Request.Header.Set("User-Agent", "unit-test-agent/1.0")
- captureConn := &openAIWSCancelSafeConn{openAIWSCaptureConn: &openAIWSCaptureConn{
- events: [][]byte{
- []byte(`{"type":"response.created","response":{"id":"resp_cancel_1","model":"gpt-5.5"}}`),
- []byte(`{"type":"response.output_text.delta","delta":"partial"}`),
- []byte(`{"type":"response.completed","response":{"id":"resp_cancel_1","model":"gpt-5.5","usage":{"input_tokens":3,"output_tokens":5}}}`),
- },
- readDelays: []time.Duration{0, 0, 50 * time.Millisecond},
- }}
- cfg := &config.Config{}
- cfg.Security.URLAllowlist.Enabled = false
- cfg.Security.URLAllowlist.AllowInsecureHTTP = true
- cfg.Gateway.OpenAIWS.Enabled = true
- cfg.Gateway.OpenAIWS.APIKeyEnabled = true
- cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
- cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
- cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool
- cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
- cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
- cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
- cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
- cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
- cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5
- cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
- pool := newOpenAIWSConnPool(cfg)
- pool.setClientDialerForTest(&openAIWSClientConnCancelDialer{conn: captureConn})
- svc := &OpenAIGatewayService{
- cfg: cfg,
- httpUpstream: &httpUpstreamRecorder{},
- cache: &stubGatewayCache{},
- openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
- toolCorrector: NewCodexToolCorrector(),
- openaiWSPool: pool,
- }
- account := &Account{
- ID: 9101,
- Name: "openai-ws-client-cancel",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-test"},
- Extra: map[string]any{
- "openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModeCtxPool,
- },
- }
- result, err := svc.Forward(ctx, c, account, []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"input_text","text":"hello"}]}`))
- require.NoError(t, err, "client cancellation must not surface as an upstream failure")
- require.NotNil(t, result)
- require.True(t, result.ClientDisconnect)
- require.Equal(t, "resp_cancel_1", result.RequestID)
- require.Equal(t, 3, result.Usage.InputTokens)
- require.Equal(t, 5, result.Usage.OutputTokens)
- require.NotContains(t, writer.body.String(), "response.failed")
- }
- type cancelOnFirstWriteResponseWriter struct {
- mu sync.Mutex
- header http.Header
- body bytes.Buffer
- cancel context.CancelFunc
- writes int
- status int
- }
- func (w *cancelOnFirstWriteResponseWriter) Header() http.Header {
- if w.header == nil {
- w.header = make(http.Header)
- }
- return w.header
- }
- func (w *cancelOnFirstWriteResponseWriter) WriteHeader(status int) {
- w.status = status
- }
- func (w *cancelOnFirstWriteResponseWriter) Write(p []byte) (int, error) {
- w.mu.Lock()
- defer w.mu.Unlock()
- if w.writes == 0 && w.cancel != nil {
- w.cancel()
- }
- w.writes++
- return w.body.Write(p)
- }
- func (w *cancelOnFirstWriteResponseWriter) Flush() {}
- type openAIWSClientConnCancelDialer struct {
- conn openAIWSClientConn
- }
- func (d *openAIWSClientConnCancelDialer) Dial(
- ctx context.Context,
- wsURL string,
- headers http.Header,
- proxyURL string,
- ) (openAIWSClientConn, int, http.Header, error) {
- return d.conn, 0, nil, nil
- }
- // openAIWSCancelSafeConn leaves a delayed event queued when its read context is
- // canceled, matching websocket frame semantics and allowing the drain read to
- // consume that event with a detached context.
- type openAIWSCancelSafeConn struct {
- *openAIWSCaptureConn
- }
- func (c *openAIWSCancelSafeConn) ReadMessage(ctx context.Context) ([]byte, error) {
- if ctx == nil {
- ctx = context.Background()
- }
- c.mu.Lock()
- if c.closed {
- c.mu.Unlock()
- return nil, errOpenAIWSConnClosed
- }
- if len(c.events) == 0 {
- c.mu.Unlock()
- return nil, io.EOF
- }
- delay := time.Duration(0)
- if len(c.readDelays) > 0 {
- delay = c.readDelays[0]
- }
- event := c.events[0]
- c.mu.Unlock()
- if delay > 0 {
- timer := time.NewTimer(delay)
- defer timer.Stop()
- select {
- case <-ctx.Done():
- return nil, ctx.Err()
- case <-timer.C:
- }
- }
- c.mu.Lock()
- defer c.mu.Unlock()
- if c.closed {
- return nil, errOpenAIWSConnClosed
- }
- if len(c.events) == 0 {
- return nil, io.EOF
- }
- c.events = c.events[1:]
- if len(c.readDelays) > 0 {
- c.readDelays = c.readDelays[1:]
- }
- return event, nil
- }
|