| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678 |
- package service
- import (
- "bytes"
- "context"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "os"
- "runtime"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- type blockingOpenAIResponseHeaderUpstream struct {
- canceled chan struct{}
- once sync.Once
- }
- type firstOutputCloseTrackingBody struct {
- io.ReadCloser
- closed chan struct{}
- once sync.Once
- }
- func (b *firstOutputCloseTrackingBody) Close() error {
- b.once.Do(func() { close(b.closed) })
- return b.ReadCloser.Close()
- }
- func (u *blockingOpenAIResponseHeaderUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- select {
- case <-req.Context().Done():
- u.once.Do(func() { close(u.canceled) })
- return nil, req.Context().Err()
- case <-time.After(1500 * time.Millisecond):
- return nil, errors.New("test upstream was not canceled before response headers")
- }
- }
- func (u *blockingOpenAIResponseHeaderUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) {
- return u.Do(req, "", 0, 0)
- }
- func TestOpenAIForwardFirstOutputTimeoutIncludesResponseHeaderWait(t *testing.T) {
- gin.SetMode(gin.TestMode)
- upstream := &blockingOpenAIResponseHeaderUpstream{canceled: make(chan struct{})}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Gateway: config.GatewayConfig{
- OpenAIFirstOutputTimeoutSeconds: 1,
- MaxLineSize: defaultMaxLineSize,
- }},
- httpUpstream: upstream,
- }
- body := []byte(`{"model":"gpt-5.5","stream":true,"reasoning":{"effort":"low"},"input":"hello"}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- account := &Account{
- ID: 1, Name: "oauth-test", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
- Status: StatusActive, Schedulable: true, Concurrency: 1,
- Credentials: map[string]any{"access_token": "test-token", "chatgpt_account_id": "test-account"},
- }
- started := time.Now()
- _, err := svc.Forward(context.Background(), c, account, body)
- require.Error(t, err)
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.Equal(t, http.StatusGatewayTimeout, failoverErr.StatusCode)
- require.Contains(t, string(failoverErr.ResponseBody), "first_output_timeout")
- require.True(t, failoverErr.SafeToFailoverAfterWrite)
- require.Less(t, time.Since(started), 1300*time.Millisecond)
- require.Empty(t, rec.Body.String())
- select {
- case <-upstream.canceled:
- default:
- t.Fatal("response-header timeout did not cancel the upstream request context")
- }
- }
- func TestOpenAINativeFirstOutputTimeoutDisabledPreservesSynchronousStream(t *testing.T) {
- svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
- OpenAIFirstOutputTimeoutSeconds: 0,
- MaxLineSize: defaultMaxLineSize,
- }}}
- resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(strings.Join([]string{
- `data: {"type":"response.created","response":{"id":"resp_disabled"}}`,
- "",
- `data: {"type":"response.completed","response":{"id":"resp_disabled","usage":{"input_tokens":1,"output_tokens":1}}}`,
- "",
- }, "\n")))}
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- result, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Contains(t, rec.Body.String(), "response.completed")
- }
- func TestOpenAINativeFirstOutputTimeoutIgnoresPreambleAndCleansReader(t *testing.T) {
- svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
- OpenAIFirstOutputTimeoutSeconds: 1,
- MaxLineSize: defaultMaxLineSize,
- }}}
- pr, pw := io.Pipe()
- writerDone := make(chan struct{})
- go func() {
- defer close(writerDone)
- defer func() { _ = pw.Close() }()
- _, _ = pw.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_slow\"}}\n\n"))
- _, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_slow\"}}\n\n"))
- time.Sleep(200 * time.Millisecond)
- }()
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- body := &firstOutputCloseTrackingBody{ReadCloser: pr, closed: make(chan struct{})}
- resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: body}
- _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now().Add(-2*time.Second), "model", "model")
- require.Error(t, err)
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.Equal(t, http.StatusGatewayTimeout, failoverErr.StatusCode)
- require.Contains(t, string(failoverErr.ResponseBody), "first_output_timeout")
- require.True(t, failoverErr.SafeToFailoverAfterWrite)
- require.Empty(t, rec.Body.String())
- select {
- case <-body.closed:
- default:
- t.Fatal("first-output timeout did not close the upstream response body")
- }
- select {
- case <-writerDone:
- case <-time.After(time.Second):
- t.Fatal("stream reader/writer goroutine did not exit after first-output timeout")
- }
- }
- func TestNewOpenAIFirstOutputTimeoutErrorRecordsCallerProxyAttribution(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- svc := &OpenAIGatewayService{}
- proxyID := int64(10060)
- account := &Account{ID: 1, Name: "acc", Platform: PlatformOpenAI, ProxyID: &proxyID, Proxy: &Proxy{ID: proxyID, Name: "ws-proxy"}}
- // WebSocket callers pass the WS attribution; HTTP callers pass the HTTP one.
- wsID, wsName := opsUpstreamWSProxyAttribution(&Account{ID: 2, Platform: PlatformOpenAI})
- _ = svc.newOpenAIFirstOutputTimeoutError(context.Background(), c, &Account{ID: 2, Name: "ws", Platform: PlatformOpenAI},
- wsID, wsName, time.Now(), "gpt-5.5", "", time.Second, "websocket_first_semantic_output", nil)
- _ = svc.newOpenAIFirstOutputTimeoutError(context.Background(), c, account,
- opsUpstreamProxyID(account), opsUpstreamProxyName(account), time.Now(), "gpt-5.5", "", time.Second, "semantic_output", nil)
- raw, ok := c.Get(OpsUpstreamErrorsKey)
- require.True(t, ok)
- events, ok := raw.([]*OpsUpstreamErrorEvent)
- require.True(t, ok)
- require.Len(t, events, 2)
- require.Equal(t, "first_output_timeout", events[0].Kind)
- require.Nil(t, events[0].ProxyID)
- require.Equal(t, opsProxyNameUnknown, events[0].ProxyName)
- require.NotNil(t, events[1].ProxyID)
- require.Equal(t, proxyID, *events[1].ProxyID)
- require.Equal(t, "ws-proxy", events[1].ProxyName)
- }
- func TestOpenAIFirstOutputTimeoutForReasoningEffort(t *testing.T) {
- svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
- OpenAIFirstOutputTimeoutSeconds: 120,
- OpenAIHighEffortFirstOutputTimeoutSeconds: 300,
- }}}
- require.Equal(t, 120*time.Second, svc.openAIFirstOutputTimeout("low"))
- require.Equal(t, 300*time.Second, svc.openAIFirstOutputTimeout("high"))
- require.Equal(t, 300*time.Second, svc.openAIFirstOutputTimeout("xhigh"))
- require.Equal(t, 300*time.Second, svc.openAIFirstOutputTimeout("max"))
- }
- func TestOpenAIFirstOutputStageDefaultLimitIsIndependentFromScannerLimit(t *testing.T) {
- stage := newDefaultOpenAIFirstOutputStage()
- defer func() { require.NoError(t, stage.Close()) }()
- require.EqualValues(t, 8*1024*1024, stage.limit)
- require.Greater(t, stage.limit, int64(68106))
- require.Less(t, stage.limit, int64(defaultMaxLineSize))
- }
- func TestOpenAIFirstOutputEventQueueSizeBackpressuresGuardedStreams(t *testing.T) {
- require.Equal(t, 1, openAIFirstOutputEventQueueSize(true))
- require.Equal(t, 16, openAIFirstOutputEventQueueSize(false))
- }
- func TestOpenAIFirstOutputDynamicScannerLimitsOnlyWhileGuardIsActive(t *testing.T) {
- var guardActive atomic.Bool
- guardActive.Store(true)
- split := openAIFirstOutputDynamicScanLines(&guardActive)
- guardLimit := openAIFirstOutputStageMaxBytes + openAIFirstOutputScannerFramingAllowance
- undelimited := bytes.Repeat([]byte("x"), guardLimit)
- _, _, err := split(undelimited, false)
- require.ErrorIs(t, err, errOpenAIFirstOutputScannerLimit)
- guardActive.Store(false)
- advance, token, err := split(undelimited, false)
- require.NoError(t, err)
- require.Zero(t, advance)
- require.Nil(t, token)
- }
- func TestOpenAIFirstOutputStageOverflowIsAtomicAndCleanupRemovesSpool(t *testing.T) {
- stage := newOpenAIFirstOutputStage(70 * 1024)
- payload := bytes.Repeat([]byte("x"), 68*1024)
- n, err := stage.Write(payload)
- require.NoError(t, err)
- require.Equal(t, len(payload), n)
- if runtime.GOOS == "windows" {
- require.Nil(t, stage.tempFile)
- require.Empty(t, stage.tempPath)
- } else {
- require.NotNil(t, stage.tempFile)
- require.NotEmpty(t, stage.tempPath)
- _, err = os.Stat(stage.tempPath)
- require.ErrorIs(t, err, os.ErrNotExist)
- stat, statErr := stage.tempFile.Stat()
- require.NoError(t, statErr)
- require.Equal(t, os.FileMode(0o600), stat.Mode().Perm())
- }
- n, err = stage.Write(bytes.Repeat([]byte("y"), 3*1024))
- require.Zero(t, n)
- require.ErrorIs(t, err, errOpenAIFirstOutputStageLimit)
- require.EqualValues(t, len(payload), stage.Buffered())
- path := stage.tempPath
- require.NoError(t, stage.Close())
- require.True(t, stage.closed)
- require.Nil(t, stage.tempFile)
- require.Empty(t, stage.tempPath)
- if path != "" {
- _, err = os.Stat(path)
- require.ErrorIs(t, err, os.ErrNotExist)
- }
- }
- func TestOpenAIFirstOutputStageCommitCopiesSpoolAndRemovesTemp(t *testing.T) {
- stage := newOpenAIFirstOutputStage(80 * 1024)
- payload := bytes.Repeat([]byte("z"), 68*1024)
- _, err := stage.Write(payload)
- require.NoError(t, err)
- path := stage.tempPath
- if runtime.GOOS == "windows" {
- require.Empty(t, path)
- require.Nil(t, stage.tempFile)
- } else {
- require.NotEmpty(t, path)
- require.NotNil(t, stage.tempFile)
- _, statErr := os.Stat(path)
- require.ErrorIs(t, statErr, os.ErrNotExist)
- }
- var downstream bytes.Buffer
- require.NoError(t, stage.CommitTo(&downstream))
- require.Equal(t, payload, downstream.Bytes())
- require.Zero(t, stage.Buffered())
- if path != "" {
- _, err = os.Stat(path)
- require.ErrorIs(t, err, os.ErrNotExist)
- }
- require.NoError(t, stage.Close())
- }
- func TestOpenAIFirstOutputStageUnlinkFailurePermanentlyFallsBackToMemoryAndRetriesCleanup(t *testing.T) {
- stage := newDefaultOpenAIFirstOutputStage()
- stage.memoryOnly = false
- t.Cleanup(func() {
- stage.removeFile = os.Remove
- _ = stage.Close()
- })
- createCalls := 0
- stage.createTemp = func() (*os.File, error) {
- createCalls++
- return os.CreateTemp("", "sub2api-openai-first-output-fallback-*")
- }
- removeCalls := 0
- stage.removeFile = func(path string) error {
- removeCalls++
- if removeCalls <= 2 {
- return errors.New("forced remove failure")
- }
- return os.Remove(path)
- }
- payload := bytes.Repeat([]byte("m"), 68*1024)
- _, err := stage.Write(payload)
- require.NoError(t, err)
- require.True(t, stage.memoryOnly)
- require.Nil(t, stage.tempFile)
- require.NotEmpty(t, stage.tempPath)
- require.Equal(t, 1, createCalls)
- stat, statErr := os.Stat(stage.tempPath)
- require.NoError(t, statErr)
- require.Zero(t, stat.Size(), "failed-unlink fallback must never write plaintext to the named file")
- _, err = stage.WriteString("more")
- require.NoError(t, err)
- require.Equal(t, 1, createCalls, "memory-only fallback must not retry CreateTemp")
- path := stage.tempPath
- cleanupErr := stage.Close()
- require.ErrorContains(t, cleanupErr, "forced remove failure")
- require.Empty(t, stage.tempPath)
- _, err = os.Stat(path)
- require.ErrorIs(t, err, os.ErrNotExist)
- require.NoError(t, stage.Close())
- }
- func TestOpenAINativeFirstOutputTimeoutDisarmsAfterSemanticOutput(t *testing.T) {
- cfg := &config.Config{Gateway: config.GatewayConfig{
- OpenAIFirstOutputTimeoutSeconds: 1,
- MaxLineSize: defaultMaxLineSize,
- }}
- svc := &OpenAIGatewayService{cfg: cfg, responseHeaderFilter: compileResponseHeaderFilter(cfg)}
- pr, pw := io.Pipe()
- go func() {
- defer func() { _ = pw.Close() }()
- _, _ = pw.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_ok\"}}\n\n"))
- _, _ = pw.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n"))
- time.Sleep(1100 * time.Millisecond)
- _, _ = pw.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n"))
- }()
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{
- "X-Request-Id": []string{"request-winning"},
- "X-Ratelimit-Remaining-Requests": []string{"42"},
- }, Body: pr}
- result, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, result.firstTokenMs)
- require.Contains(t, rec.Body.String(), "response.output_text.delta")
- require.Contains(t, rec.Body.String(), "response.completed")
- require.Equal(t, "request-winning", rec.Result().Header.Get("X-Request-Id"))
- require.Equal(t, "42", rec.Result().Header.Get("X-Ratelimit-Remaining-Requests"))
- }
- func TestOpenAINativeFirstOutputTimeoutWaitsForCompleteSemanticEvent(t *testing.T) {
- const lineSize = 68106
- prefix := `data: {"type":"response.output_text.delta","delta":"`
- suffix := `"}`
- line := prefix + strings.Repeat("x", lineSize-len(prefix)-len(suffix)) + suffix
- require.Len(t, line, lineSize)
- assertOpenAINativeLargeOpenEventTimesOutWithoutLeak(t, line)
- }
- func TestOpenAINativeFirstOutputTimeoutDoesNotLeakLargePreambleEvent(t *testing.T) {
- const lineSize = 68106
- prefix := `data: {"type":"response.created","response":{"id":"resp_partial","padding":"`
- suffix := `"}}`
- line := prefix + strings.Repeat("x", lineSize-len(prefix)-len(suffix)) + suffix
- require.Len(t, line, lineSize)
- assertOpenAINativeLargeOpenEventTimesOutWithoutLeak(t, line)
- }
- func assertOpenAINativeLargeOpenEventTimesOutWithoutLeak(t *testing.T, line string) {
- t.Helper()
- cfg := &config.Config{Gateway: config.GatewayConfig{
- OpenAIFirstOutputTimeoutSeconds: 1,
- StreamKeepaliveInterval: 1,
- MaxLineSize: defaultMaxLineSize,
- }}
- svc := &OpenAIGatewayService{cfg: cfg, responseHeaderFilter: compileResponseHeaderFilter(cfg)}
- pr, pw := io.Pipe()
- body := &firstOutputCloseTrackingBody{ReadCloser: pr, closed: make(chan struct{})}
- writerDone := make(chan struct{})
- go func() {
- defer close(writerDone)
- defer func() { _ = pw.Close() }()
- _, _ = pw.Write([]byte(line + "\n"))
- select {
- case <-body.closed:
- case <-time.After(2 * time.Second):
- }
- }()
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{
- "X-Request-Id": []string{"request-partial"},
- "X-Ratelimit-Remaining-Requests": []string{"1"},
- }, Body: body}
- _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.Equal(t, http.StatusGatewayTimeout, failoverErr.StatusCode)
- require.Contains(t, string(failoverErr.ResponseBody), "first_output_timeout")
- require.True(t, failoverErr.SafeToFailoverAfterWrite)
- require.NotContains(t, rec.Body.String(), "data:", "attempt JSON must remain private before the SSE boundary")
- require.NotContains(t, rec.Body.String(), `"type"`, "attempt JSON must remain private before the SSE boundary")
- for _, outputLine := range strings.Split(strings.TrimSpace(rec.Body.String()), "\n") {
- if outputLine != "" {
- require.True(t, strings.HasPrefix(outputLine, ":"), "only keepalive comments may precede failover: %q", outputLine)
- }
- }
- require.Empty(t, rec.Header().Values("X-Request-Id"))
- require.Empty(t, rec.Header().Values("X-Ratelimit-Remaining-Requests"))
- select {
- case <-writerDone:
- case <-time.After(time.Second):
- t.Fatal("partial-event writer did not exit after timeout closed the body")
- }
- }
- func TestOpenAINativeFirstOutputEOFDispatchesTerminalEventWithoutBlankLine(t *testing.T) {
- gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
- openAITTFTMode: OpenAITTFTModeVisible,
- expiresAt: time.Now().Add(time.Minute).UnixNano(),
- })
- t.Cleanup(func() {
- gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
- openAITTFTMode: OpenAITTFTModeSemantic,
- expiresAt: time.Now().Add(time.Minute).UnixNano(),
- })
- })
- cfg := &config.Config{Gateway: config.GatewayConfig{
- OpenAIFirstOutputTimeoutSeconds: 1,
- MaxLineSize: defaultMaxLineSize,
- }}
- svc := &OpenAIGatewayService{cfg: cfg, responseHeaderFilter: compileResponseHeaderFilter(cfg)}
- payload := `data: {"type":"response.completed","response":{"id":"resp_eof","usage":{"input_tokens":3,"output_tokens":2}}}`
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{
- "X-Request-Id": []string{"request-eof"},
- "X-Ratelimit-Remaining-Requests": []string{"17"},
- },
- Body: io.NopCloser(strings.NewReader(payload)),
- }
- result, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Nil(t, result.firstTokenMs, "usage-only terminal event is not visible output")
- require.Equal(t, "resp_eof", result.responseID)
- require.Equal(t, 3, result.usage.InputTokens)
- require.Equal(t, 2, result.usage.OutputTokens)
- require.Contains(t, rec.Body.String(), `"type":"response.completed"`)
- require.Contains(t, rec.Body.String(), `"id":"resp_eof"`)
- require.True(t, strings.HasSuffix(rec.Body.String(), "\n"))
- require.False(t, strings.HasSuffix(rec.Body.String(), "\n\n"), "EOF dispatch must not synthesize a blank line")
- require.Equal(t, "request-eof", rec.Result().Header.Get("X-Request-Id"))
- require.Equal(t, "17", rec.Result().Header.Get("X-Ratelimit-Remaining-Requests"))
- }
- func TestOpenAINativeFirstOutputStageOverflowFailsOverWithoutAttemptBytes(t *testing.T) {
- cfg := &config.Config{Gateway: config.GatewayConfig{
- OpenAIFirstOutputTimeoutSeconds: 30,
- MaxLineSize: 2 * 1024 * 1024,
- }}
- svc := &OpenAIGatewayService{cfg: cfg, responseHeaderFilter: compileResponseHeaderFilter(cfg)}
- const lineSize = 1024*1024 - 256
- prefix := `data: {"type":"response.output_text.delta","delta":"`
- suffix := `"}`
- line := prefix + strings.Repeat("x", lineSize-len(prefix)-len(suffix)) + suffix
- body := strings.Repeat(line+"\n", 9)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{
- "X-Request-Id": []string{"request-overflow"},
- "X-Ratelimit-Remaining-Requests": []string{"1"},
- },
- Body: io.NopCloser(strings.NewReader(body)),
- }
- _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
- require.True(t, failoverErr.SafeToFailoverAfterWrite)
- require.Contains(t, string(failoverErr.ResponseBody), "staging limit exceeded")
- require.Empty(t, rec.Body.String())
- require.Empty(t, rec.Header().Values("X-Request-Id"))
- require.Empty(t, rec.Header().Values("X-Ratelimit-Remaining-Requests"))
- }
- func TestOpenAINativeFirstOutputScannerRejectsOversizedLineWithoutLeak(t *testing.T) {
- cfg := &config.Config{Gateway: config.GatewayConfig{
- OpenAIFirstOutputTimeoutSeconds: 30,
- MaxLineSize: defaultMaxLineSize,
- }}
- svc := &OpenAIGatewayService{cfg: cfg, responseHeaderFilter: compileResponseHeaderFilter(cfg)}
- oversizedLine := "data: " + strings.Repeat("x", openAIFirstOutputStageMaxBytes+openAIFirstOutputScannerFramingAllowance+1024)
- body := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_private\"}}\n\n" + oversizedLine + "\n"
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{
- "X-Request-Id": []string{"request-too-large"},
- "X-Ratelimit-Remaining-Requests": []string{"1"},
- },
- Body: io.NopCloser(strings.NewReader(body)),
- }
- _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
- require.True(t, failoverErr.SafeToFailoverAfterWrite)
- require.Contains(t, string(failoverErr.ResponseBody), "line exceeds guarded first-output limit")
- require.Empty(t, rec.Body.String())
- require.Empty(t, rec.Header().Values("X-Request-Id"))
- require.Empty(t, rec.Header().Values("X-Ratelimit-Remaining-Requests"))
- }
- func TestOpenAINativeFirstOutputScannerAllowsLargeEventAfterSemanticBoundary(t *testing.T) {
- cfg := &config.Config{Gateway: config.GatewayConfig{
- OpenAIFirstOutputTimeoutSeconds: 30,
- MaxLineSize: defaultMaxLineSize,
- }}
- svc := &OpenAIGatewayService{cfg: cfg, responseHeaderFilter: compileResponseHeaderFilter(cfg)}
- largeDelta := strings.Repeat("i", openAIFirstOutputStageMaxBytes+openAIFirstOutputScannerFramingAllowance+1024)
- body := strings.Join([]string{
- `data: {"type":"response.output_text.delta","delta":"ready"}`,
- "",
- `data: {"type":"response.output_text.delta","delta":"` + largeDelta + `"}`,
- "",
- `data: {"type":"response.completed","response":{"id":"resp_large_image","usage":{"input_tokens":4,"output_tokens":3}}}`,
- "",
- }, "\n")
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"X-Request-Id": []string{"request-large-image"}},
- Body: io.NopCloser(strings.NewReader(body)),
- }
- result, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, result.firstTokenMs)
- require.Equal(t, "resp_large_image", result.responseID)
- require.Equal(t, 4, result.usage.InputTokens)
- require.Equal(t, 3, result.usage.OutputTokens)
- require.Contains(t, rec.Body.String(), `"delta":"ready"`)
- require.Contains(t, rec.Body.String(), `"id":"resp_large_image"`)
- require.Contains(t, rec.Body.String(), strings.Repeat("i", 1024))
- require.Equal(t, "request-large-image", rec.Result().Header.Get("X-Request-Id"))
- }
- func TestOpenAINativeFirstOutputTimeoutDisabledKeepsPreamblePrivateAcrossKeepalive(t *testing.T) {
- svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
- StreamKeepaliveInterval: 1,
- MaxLineSize: defaultMaxLineSize,
- }}}
- pr, pw := io.Pipe()
- go func() {
- defer func() { _ = pw.Close() }()
- _, _ = pw.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_stalled\"}}\n\n"))
- _, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_stalled\"}}\n\n"))
- time.Sleep(2100 * time.Millisecond)
- }()
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: pr}
- _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.Contains(t, rec.Body.String(), ":\n\n")
- require.NotContains(t, rec.Body.String(), "response.created")
- require.NotContains(t, rec.Body.String(), "response.in_progress")
- }
- func TestOpenAINativeFirstOutputFailoverKeepsAttemptHeadersPrivateAfterKeepaliveCommit(t *testing.T) {
- cfg := &config.Config{Gateway: config.GatewayConfig{
- OpenAIFirstOutputTimeoutSeconds: 2,
- StreamKeepaliveInterval: 1,
- MaxLineSize: defaultMaxLineSize,
- }}
- svc := &OpenAIGatewayService{
- cfg: cfg,
- responseHeaderFilter: compileResponseHeaderFilter(cfg),
- }
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- firstBody, firstWriter := io.Pipe()
- trackedFirstBody := &firstOutputCloseTrackingBody{ReadCloser: firstBody, closed: make(chan struct{})}
- firstWriterDone := make(chan struct{})
- go func() {
- defer close(firstWriterDone)
- defer func() { _ = firstWriter.Close() }()
- _, _ = firstWriter.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_first\"}}\n\n"))
- select {
- case <-trackedFirstBody.closed:
- case <-time.After(4 * time.Second):
- }
- }()
- firstResp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{
- "Content-Type": []string{"text/event-stream"},
- "X-Request-Id": []string{"request-first"},
- "X-Ratelimit-Remaining-Requests": []string{"1"},
- },
- Body: trackedFirstBody,
- }
- _, firstErr := svc.handleStreamingResponse(c.Request.Context(), firstResp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model")
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, firstErr, &failoverErr)
- require.Contains(t, rec.Body.String(), ":\n\n", "first attempt should have committed only a stable keepalive")
- require.NotContains(t, rec.Body.String(), "resp_first")
- secondResp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{
- "Content-Type": []string{"text/event-stream"},
- "X-Request-Id": []string{"request-second"},
- "X-Ratelimit-Remaining-Requests": []string{"99"},
- },
- Body: io.NopCloser(strings.NewReader(strings.Join([]string{
- `data: {"type":"response.output_text.delta","delta":"hello"}`,
- "",
- `data: {"type":"response.completed","response":{"id":"resp_second","usage":{"input_tokens":1,"output_tokens":1}}}`,
- "",
- }, "\n"))),
- }
- result, secondErr := svc.handleStreamingResponse(c.Request.Context(), secondResp, c, &Account{ID: 2, Platform: PlatformOpenAI}, time.Now(), "model", "model")
- require.NoError(t, secondErr)
- require.NotNil(t, result)
- require.Contains(t, rec.Body.String(), "resp_second")
- wireHeaders := rec.Result().Header
- require.Empty(t, wireHeaders.Values("X-Request-Id"))
- require.Empty(t, wireHeaders.Values("X-Ratelimit-Remaining-Requests"))
- require.Empty(t, rec.Header().Values("X-Request-Id"))
- require.Empty(t, rec.Header().Values("X-Ratelimit-Remaining-Requests"))
- select {
- case <-firstWriterDone:
- case <-time.After(time.Second):
- t.Fatal("first account writer did not exit after timeout")
- }
- }
|