| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646 |
- package service
- import (
- "bytes"
- "context"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- type openAIResponseFlushRecorder struct {
- header http.Header
- mu sync.Mutex
- body bytes.Buffer
- status int
- writes int
- failAfterWrites int
- flushSnapshots []string
- flushEvents chan int
- blockFlush int
- flushBlocked chan struct{}
- releaseFlush <-chan struct{}
- }
- func newOpenAIResponseFlushRecorder() *openAIResponseFlushRecorder {
- return &openAIResponseFlushRecorder{
- header: make(http.Header),
- failAfterWrites: -1,
- flushEvents: make(chan int, 16),
- }
- }
- func (w *openAIResponseFlushRecorder) Header() http.Header {
- return w.header
- }
- func (w *openAIResponseFlushRecorder) WriteHeader(statusCode int) {
- w.mu.Lock()
- defer w.mu.Unlock()
- if w.status == 0 {
- w.status = statusCode
- }
- }
- func (w *openAIResponseFlushRecorder) Write(data []byte) (int, error) {
- w.mu.Lock()
- defer w.mu.Unlock()
- if w.failAfterWrites >= 0 && w.writes >= w.failAfterWrites {
- return 0, errors.New("client disconnected")
- }
- w.writes++
- if w.status == 0 {
- w.status = http.StatusOK
- }
- return w.body.Write(data)
- }
- func (w *openAIResponseFlushRecorder) Flush() {
- w.mu.Lock()
- w.flushSnapshots = append(w.flushSnapshots, w.body.String())
- count := len(w.flushSnapshots)
- w.mu.Unlock()
- w.flushEvents <- count
- if count == w.blockFlush {
- close(w.flushBlocked)
- <-w.releaseFlush
- }
- }
- func (w *openAIResponseFlushRecorder) snapshot() (string, []string) {
- w.mu.Lock()
- defer w.mu.Unlock()
- return w.body.String(), append([]string(nil), w.flushSnapshots...)
- }
- type stagedOpenAISSEReadCloser struct {
- segments [][]byte
- gates []<-chan struct{}
- waiting []chan struct{}
- eofReached chan struct{}
- current []byte
- index int
- }
- func (r *stagedOpenAISSEReadCloser) Read(data []byte) (int, error) {
- if len(r.current) == 0 {
- if r.index >= len(r.segments) {
- if r.eofReached != nil {
- close(r.eofReached)
- r.eofReached = nil
- }
- return 0, io.EOF
- }
- index := r.index
- r.index++
- if index < len(r.waiting) && r.waiting[index] != nil {
- close(r.waiting[index])
- }
- if index < len(r.gates) && r.gates[index] != nil {
- <-r.gates[index]
- }
- r.current = r.segments[index]
- }
- n := copy(data, r.current)
- r.current = r.current[n:]
- return n, nil
- }
- func (r *stagedOpenAISSEReadCloser) Close() error { return nil }
- type openAIResponseFlushReadError struct {
- payload []byte
- err error
- sent bool
- }
- func (r *openAIResponseFlushReadError) Read(data []byte) (int, error) {
- if !r.sent {
- r.sent = true
- return copy(data, r.payload), nil
- }
- if r.err != nil {
- return 0, r.err
- }
- return 0, io.ErrUnexpectedEOF
- }
- func (r *openAIResponseFlushReadError) Close() error { return nil }
- func TestOpenAIResponseFlush_SlowEventsFlushOnceAtBoundaries(t *testing.T) {
- events := []string{
- `data: {"type":"response.output_text.delta","delta":"a"}`,
- `data: {"type":"response.output_text.delta","delta":"b"}`,
- `data: {"type":"response.output_text.delta","delta":"c"}`,
- `data: [DONE]`,
- }
- body := strings.Join(events, "\n\n") + "\n\n"
- recorder := newOpenAIResponseFlushRecorder()
- result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
- require.NoError(t, err)
- require.NotNil(t, result)
- gotBody, flushes := recorder.snapshot()
- require.Equal(t, body, gotBody)
- require.Len(t, flushes, len(events))
- for _, flushed := range flushes {
- require.True(t, strings.HasSuffix(flushed, "\n\n"), "flush must occur after a complete SSE event")
- }
- }
- func TestOpenAIResponseFlush_DataQueuedButBlankDrainsFlushesOnce(t *testing.T) {
- first := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"first\"}\n\n"
- second := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"second\"}\n\n"
- terminal := "data: [DONE]\n\n"
- allowSecond := make(chan struct{})
- allowTerminal := make(chan struct{})
- terminalWaiting := make(chan struct{})
- reader := &stagedOpenAISSEReadCloser{
- segments: [][]byte{[]byte(first), []byte(second), []byte(terminal)},
- gates: []<-chan struct{}{nil, allowSecond, allowTerminal},
- waiting: []chan struct{}{nil, nil, terminalWaiting},
- }
- releaseFirstFlush := make(chan struct{})
- recorder := newOpenAIResponseFlushRecorder()
- recorder.blockFlush = 1
- recorder.flushBlocked = make(chan struct{})
- recorder.releaseFlush = releaseFirstFlush
- resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamDataIntervalTimeout: 30})
- waitOpenAIResponseFlushSignal(t, recorder.flushBlocked)
- close(allowSecond)
- waitOpenAIResponseFlushSignal(t, terminalWaiting)
- close(releaseFirstFlush)
- waitOpenAIResponseFlushCount(t, recorder, 2)
- close(allowTerminal)
- require.NoError(t, <-errCh)
- require.NotNil(t, <-resultCh)
- gotBody, flushes := recorder.snapshot()
- require.Equal(t, first+second+terminal, gotBody)
- require.Len(t, flushes, 3)
- require.Equal(t, first, flushes[0])
- require.Equal(t, first+second, flushes[1], "blank line that drains the queue must flush the complete event exactly once")
- }
- func TestOpenAIResponseFlush_BurstDoesNotIncreaseFlushes(t *testing.T) {
- first := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"first\"}\n\n"
- burst := strings.Join([]string{
- `data: {"type":"response.output_text.delta","delta":"second"}`,
- `data: {"type":"response.output_text.delta","delta":"third"}`,
- `data: [DONE]`,
- }, "\n\n") + "\n\n"
- allowBurst := make(chan struct{})
- eofReached := make(chan struct{})
- reader := &stagedOpenAISSEReadCloser{
- segments: [][]byte{[]byte(first), []byte(burst)},
- gates: []<-chan struct{}{nil, allowBurst},
- eofReached: eofReached,
- }
- releaseFirstFlush := make(chan struct{})
- recorder := newOpenAIResponseFlushRecorder()
- recorder.blockFlush = 1
- recorder.flushBlocked = make(chan struct{})
- recorder.releaseFlush = releaseFirstFlush
- resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamDataIntervalTimeout: 30})
- waitOpenAIResponseFlushSignal(t, recorder.flushBlocked)
- close(allowBurst)
- waitOpenAIResponseFlushSignal(t, eofReached)
- close(releaseFirstFlush)
- require.NoError(t, <-errCh)
- require.NotNil(t, <-resultCh)
- gotBody, flushes := recorder.snapshot()
- require.Equal(t, first+burst, gotBody)
- require.Len(t, flushes, 2, "queued burst must remain batched until its drained event boundary")
- require.Equal(t, first, flushes[0])
- require.Equal(t, first+burst, flushes[1])
- }
- func TestOpenAIResponseFlush_CommentAndEOFOnlyFlushCompleteResidual(t *testing.T) {
- body := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"a\"}\n\n" +
- ": upstream-comment\n\n" +
- "data: [DONE]\n"
- recorder := newOpenAIResponseFlushRecorder()
- result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
- require.NoError(t, err)
- require.NotNil(t, result)
- gotBody, flushes := recorder.snapshot()
- require.Equal(t, body, gotBody)
- require.Len(t, flushes, 3)
- require.True(t, strings.HasSuffix(flushes[0], "\n\n"))
- require.True(t, strings.HasSuffix(flushes[1], "\n\n"))
- require.True(t, strings.HasSuffix(flushes[2], "data: [DONE]\n"), "EOF must flush only the remaining bytes")
- }
- func TestOpenAIResponseFlush_TerminalReadErrorFlushesResidual(t *testing.T) {
- body := "data: [DONE]\n"
- recorder := newOpenAIResponseFlushRecorder()
- result, err := runOpenAIResponseFlushTest(recorder, &openAIResponseFlushReadError{payload: []byte(body)}, config.GatewayConfig{})
- require.NoError(t, err)
- require.NotNil(t, result)
- gotBody, flushes := recorder.snapshot()
- require.Equal(t, body, gotBody)
- require.Equal(t, []string{body}, flushes)
- }
- func TestOpenAIResponseFlush_OutputWithoutTerminalFlushesResidualWithoutFailover(t *testing.T) {
- body := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n"
- recorder := newOpenAIResponseFlushRecorder()
- result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
- require.ErrorContains(t, err, "missing terminal event")
- var failoverErr *UpstreamFailoverError
- require.False(t, errors.As(err, &failoverErr))
- require.NotNil(t, result)
- gotBody, flushes := recorder.snapshot()
- require.Equal(t, body, gotBody)
- require.Equal(t, []string{body}, flushes)
- }
- func TestOpenAIResponseFlush_PreambleWithoutTerminalRemainsBufferedForFailover(t *testing.T) {
- body := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n"
- recorder := newOpenAIResponseFlushRecorder()
- result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.NotNil(t, result)
- gotBody, flushes := recorder.snapshot()
- require.Empty(t, gotBody)
- require.Empty(t, flushes)
- }
- func TestOpenAIResponseFlush_CanceledAfterOutputFlushesResidualWithoutErrorEvent(t *testing.T) {
- body := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n"
- recorder := newOpenAIResponseFlushRecorder()
- result, err := runOpenAIResponseFlushTest(recorder, &openAIResponseFlushReadError{payload: []byte(body), err: context.Canceled}, config.GatewayConfig{})
- require.ErrorIs(t, err, context.Canceled)
- require.NotNil(t, result)
- gotBody, flushes := recorder.snapshot()
- require.Equal(t, body, gotBody)
- require.Equal(t, []string{body}, flushes)
- require.NotContains(t, gotBody, "stream_read_error")
- }
- func TestOpenAIResponseFlush_KeepaliveFlushesImmediately(t *testing.T) {
- recorder := newOpenAIResponseFlushRecorder()
- reader, writer := io.Pipe()
- resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamKeepaliveInterval: 1})
- waitOpenAIResponseFlushCount(t, recorder, 1)
- _, flushes := recorder.snapshot()
- require.Equal(t, ":\n\n", flushes[0])
- _, err := writer.Write([]byte("data: [DONE]\n\n"))
- require.NoError(t, err)
- require.NoError(t, writer.Close())
- require.NoError(t, <-errCh)
- require.NotNil(t, <-resultCh)
- gotBody, flushes := recorder.snapshot()
- require.Equal(t, ":\n\ndata: [DONE]\n\n", gotBody)
- require.Len(t, flushes, 2)
- }
- func TestOpenAIResponseFlush_KeepaliveDoesNotSplitOpenEvent(t *testing.T) {
- const dataLine = `data: {"type":"response.output_text.delta","delta":"a"}`
- // Filling the 16-slot scan queue proves the main loop processed data before the reader reaches the gated blank.
- dataLines := make([]string, 17)
- for i := range dataLines {
- dataLines[i] = dataLine
- }
- partialEvent := strings.Join(dataLines, "\n") + "\n"
- completeEvent := partialEvent + "\n"
- terminal := "data: [DONE]\n\n"
- allowBlank := make(chan struct{})
- allowTerminal := make(chan struct{})
- blankWaiting := make(chan struct{})
- terminalWaiting := make(chan struct{})
- reader := &stagedOpenAISSEReadCloser{
- segments: [][]byte{[]byte(partialEvent), []byte("\n"), []byte(terminal)},
- gates: []<-chan struct{}{nil, allowBlank, allowTerminal},
- waiting: []chan struct{}{nil, blankWaiting, terminalWaiting},
- }
- recorder := newOpenAIResponseFlushRecorder()
- resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamKeepaliveInterval: 1})
- waitOpenAIResponseFlushSignal(t, blankWaiting)
- timer := time.NewTimer(1250 * time.Millisecond)
- select {
- case count := <-recorder.flushEvents:
- timer.Stop()
- t.Fatalf("keepalive flushed open event before its blank boundary: flush %d", count)
- case <-timer.C:
- }
- close(allowBlank)
- waitOpenAIResponseFlushSignal(t, terminalWaiting)
- waitOpenAIResponseFlushCount(t, recorder, 1)
- gotBody, flushes := recorder.snapshot()
- require.Equal(t, completeEvent, gotBody)
- require.Equal(t, []string{completeEvent}, flushes)
- close(allowTerminal)
- require.NoError(t, <-errCh)
- require.NotNil(t, <-resultCh)
- gotBody, flushes = recorder.snapshot()
- require.Equal(t, completeEvent+terminal, gotBody)
- require.Len(t, flushes, 2)
- require.Equal(t, completeEvent+terminal, flushes[1])
- }
- func TestOpenAIResponseFlush_FailedAndErrorEventsFlushAtBoundaries(t *testing.T) {
- t.Run("failed at EOF", func(t *testing.T) {
- body := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"a\"}\n\n" +
- "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"safety_error\",\"message\":\"blocked\"},\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}}\n"
- recorder := newOpenAIResponseFlushRecorder()
- result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
- require.Error(t, err)
- require.NotNil(t, result)
- require.Equal(t, 3, result.usage.InputTokens)
- gotBody, flushes := recorder.snapshot()
- expectedBody := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"a\"}\n\n" +
- "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"safety_error\",\"message\":\"blocked\"}}}\n"
- require.Equal(t, expectedBody, gotBody)
- require.Len(t, flushes, 2)
- require.Contains(t, flushes[1], "response.failed")
- })
- t.Run("bare error synthesizes failed before done", func(t *testing.T) {
- body := "data: {\"type\":\"error\",\"error\":{\"message\":\"failed\"}}\n\n" +
- "data: [DONE]\n\n"
- recorder := newOpenAIResponseFlushRecorder()
- result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
- require.Error(t, err)
- require.NotNil(t, result)
- gotBody, flushes := recorder.snapshot()
- require.NotContains(t, gotBody, `"type":"error"`)
- require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`))
- require.Contains(t, gotBody, `"status":"failed"`)
- require.NotContains(t, gotBody, "[DONE]")
- require.Len(t, flushes, 1)
- })
- t.Run("bare error forwards following failed and drains terminal usage", func(t *testing.T) {
- errorEvent := "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n"
- body := errorEvent +
- "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_failed\",\"usage\":{\"input_tokens\":9,\"output_tokens\":2},\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}}\n\n" +
- "data: [DONE]\n\n"
- recorder := newOpenAIResponseFlushRecorder()
- result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
- require.Error(t, err)
- require.NotNil(t, result)
- require.Equal(t, 9, result.usage.InputTokens)
- require.Equal(t, 2, result.usage.OutputTokens)
- gotBody, flushes := recorder.snapshot()
- require.NotContains(t, gotBody, `"type":"error"`)
- require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`))
- require.Contains(t, gotBody, `"id":"resp_failed"`)
- require.NotContains(t, gotBody, "[DONE]")
- require.Len(t, flushes, 1)
- })
- t.Run("non-retryable error event flushes at boundary", func(t *testing.T) {
- body := "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n" +
- "data: [DONE]\n\n"
- recorder := newOpenAIResponseFlushRecorder()
- result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
- require.Error(t, err)
- require.NotNil(t, result)
- gotBody, flushes := recorder.snapshot()
- require.NotContains(t, gotBody, `"type":"error"`)
- require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`))
- require.Len(t, flushes, 1)
- })
- }
- func TestOpenAIResponseFlush_BareErrorFollowedByCompletedUsesCompletedTerminal(t *testing.T) {
- body := "data: {\"type\":\"error\",\"error\":{\"code\":\"transient\",\"message\":\"retrying\"}}\n\n" +
- "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_recovered\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":7,\"output_tokens\":3}}}\n\n"
- recorder := newOpenAIResponseFlushRecorder()
- result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, 7, result.usage.InputTokens)
- require.Equal(t, 3, result.usage.OutputTokens)
- gotBody, _ := recorder.snapshot()
- require.NotContains(t, gotBody, `"type":"error"`)
- require.NotContains(t, gotBody, `"type":"response.failed"`)
- require.Contains(t, gotBody, `"type":"response.completed"`)
- }
- func TestOpenAIResponseFlush_CompatibleAPIKeyDoesNotUseCodexBareErrorSynthesis(t *testing.T) {
- body := "data: {\"type\":\"error\",\"error\":{\"code\":\"provider_error\",\"message\":\"provider failed\"}}\n\n"
- recorder := newOpenAIResponseFlushRecorder()
- account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
- result, err := runOpenAIResponseFlushTestWithAccount(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{}, account)
- require.Error(t, err)
- require.NotNil(t, result)
- gotBody, _ := recorder.snapshot()
- require.Contains(t, gotBody, `"type":"error"`)
- require.NotContains(t, gotBody, `"type":"response.failed"`)
- }
- func TestOpenAIResponseFlush_RecentBareErrorAllowsCompletedBeforeIdleTimeout(t *testing.T) {
- reader, writer := io.Pipe()
- defer func() { _ = writer.Close() }()
- recorder := newOpenAIResponseFlushRecorder()
- resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamDataIntervalTimeout: 1})
- // Place the bare error shortly before the first ticker firing. It is fresh
- // data, so the ticker must leave the stream open for an authoritative event.
- time.Sleep(700 * time.Millisecond)
- _, err := io.WriteString(writer, "data: {\"type\":\"error\",\"error\":{\"code\":\"transient\",\"message\":\"retrying\"}}\n\n")
- require.NoError(t, err)
- time.Sleep(500 * time.Millisecond)
- _, err = io.WriteString(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_late\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\n\n")
- require.NoError(t, err)
- require.NoError(t, writer.Close())
- require.NoError(t, <-errCh)
- result := <-resultCh
- require.NotNil(t, result)
- require.Equal(t, 5, result.usage.InputTokens)
- gotBody, _ := recorder.snapshot()
- require.Contains(t, gotBody, `"type":"response.completed"`)
- require.NotContains(t, gotBody, `"type":"response.failed"`)
- }
- func TestOpenAIResponseFlush_BareErrorTimeoutSynthesizesFailed(t *testing.T) {
- tests := []struct {
- name string
- cfg config.GatewayConfig
- }{
- {
- name: "stream interval timeout",
- cfg: config.GatewayConfig{StreamDataIntervalTimeout: 1},
- },
- {
- name: "first output timeout",
- cfg: config.GatewayConfig{OpenAIFirstOutputTimeoutSeconds: 1},
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- reader, writer := io.Pipe()
- defer func() { _ = writer.Close() }()
- recorder := newOpenAIResponseFlushRecorder()
- resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, tt.cfg)
- _, writeErr := io.WriteString(writer, "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n")
- require.NoError(t, writeErr)
- select {
- case err := <-errCh:
- require.Error(t, err)
- require.Contains(t, err.Error(), "upstream response failed")
- case <-time.After(3 * time.Second):
- t.Fatal("timed out waiting for bare error terminal synthesis")
- }
- require.NotNil(t, <-resultCh)
- body, flushes := recorder.snapshot()
- require.NotContains(t, body, `"type":"error"`)
- require.Equal(t, 1, strings.Count(body, `"type":"response.failed"`))
- require.Len(t, flushes, 1)
- })
- }
- }
- func TestOpenAIResponseFlush_ReusedTypeKeepsSSEBytesAndTerminalSemantics(t *testing.T) {
- tests := []struct {
- name string
- body string
- flushCount int
- }{
- {
- name: "whitespace around done",
- body: "data: \t[DONE] \n\n",
- flushCount: 1,
- },
- {
- name: "invalid JSON before done",
- body: "data: {\"type\":\n\ndata: [DONE]\n\n",
- flushCount: 2,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- recorder := newOpenAIResponseFlushRecorder()
- result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(tt.body)), config.GatewayConfig{})
- require.NoError(t, err)
- require.NotNil(t, result)
- gotBody, flushes := recorder.snapshot()
- require.Equal(t, tt.body, gotBody)
- require.Len(t, flushes, tt.flushCount)
- })
- }
- }
- func TestOpenAIResponseFlush_ClientDisconnectStillDrainsUsage(t *testing.T) {
- first := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"a\"}\n\n"
- terminal := "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":7,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":2}}}}\n\n"
- recorder := newOpenAIResponseFlushRecorder()
- recorder.failAfterWrites = 1
- result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(first+terminal)), config.GatewayConfig{})
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, 7, result.usage.InputTokens)
- require.Equal(t, 5, result.usage.OutputTokens)
- require.Equal(t, 2, result.usage.CacheReadInputTokens)
- gotBody, flushes := recorder.snapshot()
- require.Equal(t, first, gotBody)
- require.Len(t, flushes, 1)
- }
- func runOpenAIResponseFlushTest(recorder *openAIResponseFlushRecorder, body io.ReadCloser, gatewayCfg config.GatewayConfig) (*openaiStreamingResult, error) {
- return runOpenAIResponseFlushTestWithAccount(recorder, body, gatewayCfg, &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth})
- }
- func runOpenAIResponseFlushTestWithAccount(recorder *openAIResponseFlushRecorder, body io.ReadCloser, gatewayCfg config.GatewayConfig, account *Account) (*openaiStreamingResult, error) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Gateway: gatewayCfg},
- toolCorrector: NewCodexToolCorrector(),
- }
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: body,
- }
- return svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
- }
- func runOpenAIResponseFlushTestAsync(recorder *openAIResponseFlushRecorder, body io.ReadCloser, gatewayCfg config.GatewayConfig) (<-chan *openaiStreamingResult, <-chan error) {
- resultCh := make(chan *openaiStreamingResult, 1)
- errCh := make(chan error, 1)
- go func() {
- result, err := runOpenAIResponseFlushTest(recorder, body, gatewayCfg)
- resultCh <- result
- errCh <- err
- }()
- return resultCh, errCh
- }
- func waitOpenAIResponseFlushCount(t *testing.T, recorder *openAIResponseFlushRecorder, want int) {
- t.Helper()
- timer := time.NewTimer(3 * time.Second)
- defer timer.Stop()
- for {
- select {
- case count := <-recorder.flushEvents:
- if count >= want {
- return
- }
- case <-timer.C:
- t.Fatalf("timed out waiting for flush %d", want)
- }
- }
- }
- func waitOpenAIResponseFlushSignal(t *testing.T, signal <-chan struct{}) {
- t.Helper()
- select {
- case <-signal:
- case <-time.After(3 * time.Second):
- t.Fatal("timed out waiting for stream signal")
- }
- }
|