openai_gateway_response_flush_test.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "sync"
  11. "testing"
  12. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/config"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/require"
  16. )
  17. type openAIResponseFlushRecorder struct {
  18. header http.Header
  19. mu sync.Mutex
  20. body bytes.Buffer
  21. status int
  22. writes int
  23. failAfterWrites int
  24. flushSnapshots []string
  25. flushEvents chan int
  26. blockFlush int
  27. flushBlocked chan struct{}
  28. releaseFlush <-chan struct{}
  29. }
  30. func newOpenAIResponseFlushRecorder() *openAIResponseFlushRecorder {
  31. return &openAIResponseFlushRecorder{
  32. header: make(http.Header),
  33. failAfterWrites: -1,
  34. flushEvents: make(chan int, 16),
  35. }
  36. }
  37. func (w *openAIResponseFlushRecorder) Header() http.Header {
  38. return w.header
  39. }
  40. func (w *openAIResponseFlushRecorder) WriteHeader(statusCode int) {
  41. w.mu.Lock()
  42. defer w.mu.Unlock()
  43. if w.status == 0 {
  44. w.status = statusCode
  45. }
  46. }
  47. func (w *openAIResponseFlushRecorder) Write(data []byte) (int, error) {
  48. w.mu.Lock()
  49. defer w.mu.Unlock()
  50. if w.failAfterWrites >= 0 && w.writes >= w.failAfterWrites {
  51. return 0, errors.New("client disconnected")
  52. }
  53. w.writes++
  54. if w.status == 0 {
  55. w.status = http.StatusOK
  56. }
  57. return w.body.Write(data)
  58. }
  59. func (w *openAIResponseFlushRecorder) Flush() {
  60. w.mu.Lock()
  61. w.flushSnapshots = append(w.flushSnapshots, w.body.String())
  62. count := len(w.flushSnapshots)
  63. w.mu.Unlock()
  64. w.flushEvents <- count
  65. if count == w.blockFlush {
  66. close(w.flushBlocked)
  67. <-w.releaseFlush
  68. }
  69. }
  70. func (w *openAIResponseFlushRecorder) snapshot() (string, []string) {
  71. w.mu.Lock()
  72. defer w.mu.Unlock()
  73. return w.body.String(), append([]string(nil), w.flushSnapshots...)
  74. }
  75. type stagedOpenAISSEReadCloser struct {
  76. segments [][]byte
  77. gates []<-chan struct{}
  78. waiting []chan struct{}
  79. eofReached chan struct{}
  80. current []byte
  81. index int
  82. }
  83. func (r *stagedOpenAISSEReadCloser) Read(data []byte) (int, error) {
  84. if len(r.current) == 0 {
  85. if r.index >= len(r.segments) {
  86. if r.eofReached != nil {
  87. close(r.eofReached)
  88. r.eofReached = nil
  89. }
  90. return 0, io.EOF
  91. }
  92. index := r.index
  93. r.index++
  94. if index < len(r.waiting) && r.waiting[index] != nil {
  95. close(r.waiting[index])
  96. }
  97. if index < len(r.gates) && r.gates[index] != nil {
  98. <-r.gates[index]
  99. }
  100. r.current = r.segments[index]
  101. }
  102. n := copy(data, r.current)
  103. r.current = r.current[n:]
  104. return n, nil
  105. }
  106. func (r *stagedOpenAISSEReadCloser) Close() error { return nil }
  107. type openAIResponseFlushReadError struct {
  108. payload []byte
  109. err error
  110. sent bool
  111. }
  112. func (r *openAIResponseFlushReadError) Read(data []byte) (int, error) {
  113. if !r.sent {
  114. r.sent = true
  115. return copy(data, r.payload), nil
  116. }
  117. if r.err != nil {
  118. return 0, r.err
  119. }
  120. return 0, io.ErrUnexpectedEOF
  121. }
  122. func (r *openAIResponseFlushReadError) Close() error { return nil }
  123. func TestOpenAIResponseFlush_SlowEventsFlushOnceAtBoundaries(t *testing.T) {
  124. events := []string{
  125. `data: {"type":"response.output_text.delta","delta":"a"}`,
  126. `data: {"type":"response.output_text.delta","delta":"b"}`,
  127. `data: {"type":"response.output_text.delta","delta":"c"}`,
  128. `data: [DONE]`,
  129. }
  130. body := strings.Join(events, "\n\n") + "\n\n"
  131. recorder := newOpenAIResponseFlushRecorder()
  132. result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
  133. require.NoError(t, err)
  134. require.NotNil(t, result)
  135. gotBody, flushes := recorder.snapshot()
  136. require.Equal(t, body, gotBody)
  137. require.Len(t, flushes, len(events))
  138. for _, flushed := range flushes {
  139. require.True(t, strings.HasSuffix(flushed, "\n\n"), "flush must occur after a complete SSE event")
  140. }
  141. }
  142. func TestOpenAIResponseFlush_DataQueuedButBlankDrainsFlushesOnce(t *testing.T) {
  143. first := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"first\"}\n\n"
  144. second := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"second\"}\n\n"
  145. terminal := "data: [DONE]\n\n"
  146. allowSecond := make(chan struct{})
  147. allowTerminal := make(chan struct{})
  148. terminalWaiting := make(chan struct{})
  149. reader := &stagedOpenAISSEReadCloser{
  150. segments: [][]byte{[]byte(first), []byte(second), []byte(terminal)},
  151. gates: []<-chan struct{}{nil, allowSecond, allowTerminal},
  152. waiting: []chan struct{}{nil, nil, terminalWaiting},
  153. }
  154. releaseFirstFlush := make(chan struct{})
  155. recorder := newOpenAIResponseFlushRecorder()
  156. recorder.blockFlush = 1
  157. recorder.flushBlocked = make(chan struct{})
  158. recorder.releaseFlush = releaseFirstFlush
  159. resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamDataIntervalTimeout: 30})
  160. waitOpenAIResponseFlushSignal(t, recorder.flushBlocked)
  161. close(allowSecond)
  162. waitOpenAIResponseFlushSignal(t, terminalWaiting)
  163. close(releaseFirstFlush)
  164. waitOpenAIResponseFlushCount(t, recorder, 2)
  165. close(allowTerminal)
  166. require.NoError(t, <-errCh)
  167. require.NotNil(t, <-resultCh)
  168. gotBody, flushes := recorder.snapshot()
  169. require.Equal(t, first+second+terminal, gotBody)
  170. require.Len(t, flushes, 3)
  171. require.Equal(t, first, flushes[0])
  172. require.Equal(t, first+second, flushes[1], "blank line that drains the queue must flush the complete event exactly once")
  173. }
  174. func TestOpenAIResponseFlush_BurstDoesNotIncreaseFlushes(t *testing.T) {
  175. first := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"first\"}\n\n"
  176. burst := strings.Join([]string{
  177. `data: {"type":"response.output_text.delta","delta":"second"}`,
  178. `data: {"type":"response.output_text.delta","delta":"third"}`,
  179. `data: [DONE]`,
  180. }, "\n\n") + "\n\n"
  181. allowBurst := make(chan struct{})
  182. eofReached := make(chan struct{})
  183. reader := &stagedOpenAISSEReadCloser{
  184. segments: [][]byte{[]byte(first), []byte(burst)},
  185. gates: []<-chan struct{}{nil, allowBurst},
  186. eofReached: eofReached,
  187. }
  188. releaseFirstFlush := make(chan struct{})
  189. recorder := newOpenAIResponseFlushRecorder()
  190. recorder.blockFlush = 1
  191. recorder.flushBlocked = make(chan struct{})
  192. recorder.releaseFlush = releaseFirstFlush
  193. resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamDataIntervalTimeout: 30})
  194. waitOpenAIResponseFlushSignal(t, recorder.flushBlocked)
  195. close(allowBurst)
  196. waitOpenAIResponseFlushSignal(t, eofReached)
  197. close(releaseFirstFlush)
  198. require.NoError(t, <-errCh)
  199. require.NotNil(t, <-resultCh)
  200. gotBody, flushes := recorder.snapshot()
  201. require.Equal(t, first+burst, gotBody)
  202. require.Len(t, flushes, 2, "queued burst must remain batched until its drained event boundary")
  203. require.Equal(t, first, flushes[0])
  204. require.Equal(t, first+burst, flushes[1])
  205. }
  206. func TestOpenAIResponseFlush_CommentAndEOFOnlyFlushCompleteResidual(t *testing.T) {
  207. body := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"a\"}\n\n" +
  208. ": upstream-comment\n\n" +
  209. "data: [DONE]\n"
  210. recorder := newOpenAIResponseFlushRecorder()
  211. result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
  212. require.NoError(t, err)
  213. require.NotNil(t, result)
  214. gotBody, flushes := recorder.snapshot()
  215. require.Equal(t, body, gotBody)
  216. require.Len(t, flushes, 3)
  217. require.True(t, strings.HasSuffix(flushes[0], "\n\n"))
  218. require.True(t, strings.HasSuffix(flushes[1], "\n\n"))
  219. require.True(t, strings.HasSuffix(flushes[2], "data: [DONE]\n"), "EOF must flush only the remaining bytes")
  220. }
  221. func TestOpenAIResponseFlush_TerminalReadErrorFlushesResidual(t *testing.T) {
  222. body := "data: [DONE]\n"
  223. recorder := newOpenAIResponseFlushRecorder()
  224. result, err := runOpenAIResponseFlushTest(recorder, &openAIResponseFlushReadError{payload: []byte(body)}, config.GatewayConfig{})
  225. require.NoError(t, err)
  226. require.NotNil(t, result)
  227. gotBody, flushes := recorder.snapshot()
  228. require.Equal(t, body, gotBody)
  229. require.Equal(t, []string{body}, flushes)
  230. }
  231. func TestOpenAIResponseFlush_OutputWithoutTerminalFlushesResidualWithoutFailover(t *testing.T) {
  232. body := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n"
  233. recorder := newOpenAIResponseFlushRecorder()
  234. result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
  235. require.ErrorContains(t, err, "missing terminal event")
  236. var failoverErr *UpstreamFailoverError
  237. require.False(t, errors.As(err, &failoverErr))
  238. require.NotNil(t, result)
  239. gotBody, flushes := recorder.snapshot()
  240. require.Equal(t, body, gotBody)
  241. require.Equal(t, []string{body}, flushes)
  242. }
  243. func TestOpenAIResponseFlush_PreambleWithoutTerminalRemainsBufferedForFailover(t *testing.T) {
  244. body := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n"
  245. recorder := newOpenAIResponseFlushRecorder()
  246. result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
  247. var failoverErr *UpstreamFailoverError
  248. require.ErrorAs(t, err, &failoverErr)
  249. require.NotNil(t, result)
  250. gotBody, flushes := recorder.snapshot()
  251. require.Empty(t, gotBody)
  252. require.Empty(t, flushes)
  253. }
  254. func TestOpenAIResponseFlush_CanceledAfterOutputFlushesResidualWithoutErrorEvent(t *testing.T) {
  255. body := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n"
  256. recorder := newOpenAIResponseFlushRecorder()
  257. result, err := runOpenAIResponseFlushTest(recorder, &openAIResponseFlushReadError{payload: []byte(body), err: context.Canceled}, config.GatewayConfig{})
  258. require.ErrorIs(t, err, context.Canceled)
  259. require.NotNil(t, result)
  260. gotBody, flushes := recorder.snapshot()
  261. require.Equal(t, body, gotBody)
  262. require.Equal(t, []string{body}, flushes)
  263. require.NotContains(t, gotBody, "stream_read_error")
  264. }
  265. func TestOpenAIResponseFlush_KeepaliveFlushesImmediately(t *testing.T) {
  266. recorder := newOpenAIResponseFlushRecorder()
  267. reader, writer := io.Pipe()
  268. resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamKeepaliveInterval: 1})
  269. waitOpenAIResponseFlushCount(t, recorder, 1)
  270. _, flushes := recorder.snapshot()
  271. require.Equal(t, ":\n\n", flushes[0])
  272. _, err := writer.Write([]byte("data: [DONE]\n\n"))
  273. require.NoError(t, err)
  274. require.NoError(t, writer.Close())
  275. require.NoError(t, <-errCh)
  276. require.NotNil(t, <-resultCh)
  277. gotBody, flushes := recorder.snapshot()
  278. require.Equal(t, ":\n\ndata: [DONE]\n\n", gotBody)
  279. require.Len(t, flushes, 2)
  280. }
  281. func TestOpenAIResponseFlush_KeepaliveDoesNotSplitOpenEvent(t *testing.T) {
  282. const dataLine = `data: {"type":"response.output_text.delta","delta":"a"}`
  283. // Filling the 16-slot scan queue proves the main loop processed data before the reader reaches the gated blank.
  284. dataLines := make([]string, 17)
  285. for i := range dataLines {
  286. dataLines[i] = dataLine
  287. }
  288. partialEvent := strings.Join(dataLines, "\n") + "\n"
  289. completeEvent := partialEvent + "\n"
  290. terminal := "data: [DONE]\n\n"
  291. allowBlank := make(chan struct{})
  292. allowTerminal := make(chan struct{})
  293. blankWaiting := make(chan struct{})
  294. terminalWaiting := make(chan struct{})
  295. reader := &stagedOpenAISSEReadCloser{
  296. segments: [][]byte{[]byte(partialEvent), []byte("\n"), []byte(terminal)},
  297. gates: []<-chan struct{}{nil, allowBlank, allowTerminal},
  298. waiting: []chan struct{}{nil, blankWaiting, terminalWaiting},
  299. }
  300. recorder := newOpenAIResponseFlushRecorder()
  301. resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamKeepaliveInterval: 1})
  302. waitOpenAIResponseFlushSignal(t, blankWaiting)
  303. timer := time.NewTimer(1250 * time.Millisecond)
  304. select {
  305. case count := <-recorder.flushEvents:
  306. timer.Stop()
  307. t.Fatalf("keepalive flushed open event before its blank boundary: flush %d", count)
  308. case <-timer.C:
  309. }
  310. close(allowBlank)
  311. waitOpenAIResponseFlushSignal(t, terminalWaiting)
  312. waitOpenAIResponseFlushCount(t, recorder, 1)
  313. gotBody, flushes := recorder.snapshot()
  314. require.Equal(t, completeEvent, gotBody)
  315. require.Equal(t, []string{completeEvent}, flushes)
  316. close(allowTerminal)
  317. require.NoError(t, <-errCh)
  318. require.NotNil(t, <-resultCh)
  319. gotBody, flushes = recorder.snapshot()
  320. require.Equal(t, completeEvent+terminal, gotBody)
  321. require.Len(t, flushes, 2)
  322. require.Equal(t, completeEvent+terminal, flushes[1])
  323. }
  324. func TestOpenAIResponseFlush_FailedAndErrorEventsFlushAtBoundaries(t *testing.T) {
  325. t.Run("failed at EOF", func(t *testing.T) {
  326. body := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"a\"}\n\n" +
  327. "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"safety_error\",\"message\":\"blocked\"},\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}}\n"
  328. recorder := newOpenAIResponseFlushRecorder()
  329. result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
  330. require.Error(t, err)
  331. require.NotNil(t, result)
  332. require.Equal(t, 3, result.usage.InputTokens)
  333. gotBody, flushes := recorder.snapshot()
  334. expectedBody := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"a\"}\n\n" +
  335. "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"safety_error\",\"message\":\"blocked\"}}}\n"
  336. require.Equal(t, expectedBody, gotBody)
  337. require.Len(t, flushes, 2)
  338. require.Contains(t, flushes[1], "response.failed")
  339. })
  340. t.Run("bare error synthesizes failed before done", func(t *testing.T) {
  341. body := "data: {\"type\":\"error\",\"error\":{\"message\":\"failed\"}}\n\n" +
  342. "data: [DONE]\n\n"
  343. recorder := newOpenAIResponseFlushRecorder()
  344. result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
  345. require.Error(t, err)
  346. require.NotNil(t, result)
  347. gotBody, flushes := recorder.snapshot()
  348. require.NotContains(t, gotBody, `"type":"error"`)
  349. require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`))
  350. require.Contains(t, gotBody, `"status":"failed"`)
  351. require.NotContains(t, gotBody, "[DONE]")
  352. require.Len(t, flushes, 1)
  353. })
  354. t.Run("bare error forwards following failed and drains terminal usage", func(t *testing.T) {
  355. errorEvent := "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n"
  356. body := errorEvent +
  357. "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_failed\",\"usage\":{\"input_tokens\":9,\"output_tokens\":2},\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}}\n\n" +
  358. "data: [DONE]\n\n"
  359. recorder := newOpenAIResponseFlushRecorder()
  360. result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
  361. require.Error(t, err)
  362. require.NotNil(t, result)
  363. require.Equal(t, 9, result.usage.InputTokens)
  364. require.Equal(t, 2, result.usage.OutputTokens)
  365. gotBody, flushes := recorder.snapshot()
  366. require.NotContains(t, gotBody, `"type":"error"`)
  367. require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`))
  368. require.Contains(t, gotBody, `"id":"resp_failed"`)
  369. require.NotContains(t, gotBody, "[DONE]")
  370. require.Len(t, flushes, 1)
  371. })
  372. t.Run("non-retryable error event flushes at boundary", func(t *testing.T) {
  373. body := "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n" +
  374. "data: [DONE]\n\n"
  375. recorder := newOpenAIResponseFlushRecorder()
  376. result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
  377. require.Error(t, err)
  378. require.NotNil(t, result)
  379. gotBody, flushes := recorder.snapshot()
  380. require.NotContains(t, gotBody, `"type":"error"`)
  381. require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`))
  382. require.Len(t, flushes, 1)
  383. })
  384. }
  385. func TestOpenAIResponseFlush_BareErrorFollowedByCompletedUsesCompletedTerminal(t *testing.T) {
  386. body := "data: {\"type\":\"error\",\"error\":{\"code\":\"transient\",\"message\":\"retrying\"}}\n\n" +
  387. "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_recovered\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":7,\"output_tokens\":3}}}\n\n"
  388. recorder := newOpenAIResponseFlushRecorder()
  389. result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
  390. require.NoError(t, err)
  391. require.NotNil(t, result)
  392. require.Equal(t, 7, result.usage.InputTokens)
  393. require.Equal(t, 3, result.usage.OutputTokens)
  394. gotBody, _ := recorder.snapshot()
  395. require.NotContains(t, gotBody, `"type":"error"`)
  396. require.NotContains(t, gotBody, `"type":"response.failed"`)
  397. require.Contains(t, gotBody, `"type":"response.completed"`)
  398. }
  399. func TestOpenAIResponseFlush_CompatibleAPIKeyDoesNotUseCodexBareErrorSynthesis(t *testing.T) {
  400. body := "data: {\"type\":\"error\",\"error\":{\"code\":\"provider_error\",\"message\":\"provider failed\"}}\n\n"
  401. recorder := newOpenAIResponseFlushRecorder()
  402. account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  403. result, err := runOpenAIResponseFlushTestWithAccount(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{}, account)
  404. require.Error(t, err)
  405. require.NotNil(t, result)
  406. gotBody, _ := recorder.snapshot()
  407. require.Contains(t, gotBody, `"type":"error"`)
  408. require.NotContains(t, gotBody, `"type":"response.failed"`)
  409. }
  410. func TestOpenAIResponseFlush_RecentBareErrorAllowsCompletedBeforeIdleTimeout(t *testing.T) {
  411. reader, writer := io.Pipe()
  412. defer func() { _ = writer.Close() }()
  413. recorder := newOpenAIResponseFlushRecorder()
  414. resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamDataIntervalTimeout: 1})
  415. // Place the bare error shortly before the first ticker firing. It is fresh
  416. // data, so the ticker must leave the stream open for an authoritative event.
  417. time.Sleep(700 * time.Millisecond)
  418. _, err := io.WriteString(writer, "data: {\"type\":\"error\",\"error\":{\"code\":\"transient\",\"message\":\"retrying\"}}\n\n")
  419. require.NoError(t, err)
  420. time.Sleep(500 * time.Millisecond)
  421. _, err = io.WriteString(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_late\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\n\n")
  422. require.NoError(t, err)
  423. require.NoError(t, writer.Close())
  424. require.NoError(t, <-errCh)
  425. result := <-resultCh
  426. require.NotNil(t, result)
  427. require.Equal(t, 5, result.usage.InputTokens)
  428. gotBody, _ := recorder.snapshot()
  429. require.Contains(t, gotBody, `"type":"response.completed"`)
  430. require.NotContains(t, gotBody, `"type":"response.failed"`)
  431. }
  432. func TestOpenAIResponseFlush_BareErrorTimeoutSynthesizesFailed(t *testing.T) {
  433. tests := []struct {
  434. name string
  435. cfg config.GatewayConfig
  436. }{
  437. {
  438. name: "stream interval timeout",
  439. cfg: config.GatewayConfig{StreamDataIntervalTimeout: 1},
  440. },
  441. {
  442. name: "first output timeout",
  443. cfg: config.GatewayConfig{OpenAIFirstOutputTimeoutSeconds: 1},
  444. },
  445. }
  446. for _, tt := range tests {
  447. t.Run(tt.name, func(t *testing.T) {
  448. reader, writer := io.Pipe()
  449. defer func() { _ = writer.Close() }()
  450. recorder := newOpenAIResponseFlushRecorder()
  451. resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, tt.cfg)
  452. _, writeErr := io.WriteString(writer, "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n")
  453. require.NoError(t, writeErr)
  454. select {
  455. case err := <-errCh:
  456. require.Error(t, err)
  457. require.Contains(t, err.Error(), "upstream response failed")
  458. case <-time.After(3 * time.Second):
  459. t.Fatal("timed out waiting for bare error terminal synthesis")
  460. }
  461. require.NotNil(t, <-resultCh)
  462. body, flushes := recorder.snapshot()
  463. require.NotContains(t, body, `"type":"error"`)
  464. require.Equal(t, 1, strings.Count(body, `"type":"response.failed"`))
  465. require.Len(t, flushes, 1)
  466. })
  467. }
  468. }
  469. func TestOpenAIResponseFlush_ReusedTypeKeepsSSEBytesAndTerminalSemantics(t *testing.T) {
  470. tests := []struct {
  471. name string
  472. body string
  473. flushCount int
  474. }{
  475. {
  476. name: "whitespace around done",
  477. body: "data: \t[DONE] \n\n",
  478. flushCount: 1,
  479. },
  480. {
  481. name: "invalid JSON before done",
  482. body: "data: {\"type\":\n\ndata: [DONE]\n\n",
  483. flushCount: 2,
  484. },
  485. }
  486. for _, tt := range tests {
  487. t.Run(tt.name, func(t *testing.T) {
  488. recorder := newOpenAIResponseFlushRecorder()
  489. result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(tt.body)), config.GatewayConfig{})
  490. require.NoError(t, err)
  491. require.NotNil(t, result)
  492. gotBody, flushes := recorder.snapshot()
  493. require.Equal(t, tt.body, gotBody)
  494. require.Len(t, flushes, tt.flushCount)
  495. })
  496. }
  497. }
  498. func TestOpenAIResponseFlush_ClientDisconnectStillDrainsUsage(t *testing.T) {
  499. first := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"a\"}\n\n"
  500. terminal := "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":7,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":2}}}}\n\n"
  501. recorder := newOpenAIResponseFlushRecorder()
  502. recorder.failAfterWrites = 1
  503. result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(first+terminal)), config.GatewayConfig{})
  504. require.NoError(t, err)
  505. require.NotNil(t, result)
  506. require.Equal(t, 7, result.usage.InputTokens)
  507. require.Equal(t, 5, result.usage.OutputTokens)
  508. require.Equal(t, 2, result.usage.CacheReadInputTokens)
  509. gotBody, flushes := recorder.snapshot()
  510. require.Equal(t, first, gotBody)
  511. require.Len(t, flushes, 1)
  512. }
  513. func runOpenAIResponseFlushTest(recorder *openAIResponseFlushRecorder, body io.ReadCloser, gatewayCfg config.GatewayConfig) (*openaiStreamingResult, error) {
  514. return runOpenAIResponseFlushTestWithAccount(recorder, body, gatewayCfg, &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth})
  515. }
  516. func runOpenAIResponseFlushTestWithAccount(recorder *openAIResponseFlushRecorder, body io.ReadCloser, gatewayCfg config.GatewayConfig, account *Account) (*openaiStreamingResult, error) {
  517. gin.SetMode(gin.TestMode)
  518. c, _ := gin.CreateTestContext(recorder)
  519. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  520. svc := &OpenAIGatewayService{
  521. cfg: &config.Config{Gateway: gatewayCfg},
  522. toolCorrector: NewCodexToolCorrector(),
  523. }
  524. resp := &http.Response{
  525. StatusCode: http.StatusOK,
  526. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  527. Body: body,
  528. }
  529. return svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
  530. }
  531. func runOpenAIResponseFlushTestAsync(recorder *openAIResponseFlushRecorder, body io.ReadCloser, gatewayCfg config.GatewayConfig) (<-chan *openaiStreamingResult, <-chan error) {
  532. resultCh := make(chan *openaiStreamingResult, 1)
  533. errCh := make(chan error, 1)
  534. go func() {
  535. result, err := runOpenAIResponseFlushTest(recorder, body, gatewayCfg)
  536. resultCh <- result
  537. errCh <- err
  538. }()
  539. return resultCh, errCh
  540. }
  541. func waitOpenAIResponseFlushCount(t *testing.T, recorder *openAIResponseFlushRecorder, want int) {
  542. t.Helper()
  543. timer := time.NewTimer(3 * time.Second)
  544. defer timer.Stop()
  545. for {
  546. select {
  547. case count := <-recorder.flushEvents:
  548. if count >= want {
  549. return
  550. }
  551. case <-timer.C:
  552. t.Fatalf("timed out waiting for flush %d", want)
  553. }
  554. }
  555. }
  556. func waitOpenAIResponseFlushSignal(t *testing.T, signal <-chan struct{}) {
  557. t.Helper()
  558. select {
  559. case <-signal:
  560. case <-time.After(3 * time.Second):
  561. t.Fatal("timed out waiting for stream signal")
  562. }
  563. }