openai_gateway_passthrough_flush_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. )
  16. type passthroughFlushTestWriter struct {
  17. gin.ResponseWriter
  18. recorder *httptest.ResponseRecorder
  19. failAfterWrites int
  20. successfulWrites int
  21. failedWrites int
  22. flushBodyLengths []int
  23. }
  24. func (w *passthroughFlushTestWriter) Write(data []byte) (int, error) {
  25. if w.failAfterWrites >= 0 && w.successfulWrites >= w.failAfterWrites {
  26. w.failedWrites++
  27. return 0, errors.New("client disconnected")
  28. }
  29. n, err := w.ResponseWriter.Write(data)
  30. if err == nil {
  31. w.successfulWrites++
  32. }
  33. return n, err
  34. }
  35. func (w *passthroughFlushTestWriter) WriteString(data string) (int, error) {
  36. return w.Write([]byte(data))
  37. }
  38. func (w *passthroughFlushTestWriter) Flush() {
  39. w.ResponseWriter.Flush()
  40. w.flushBodyLengths = append(w.flushBodyLengths, w.recorder.Body.Len())
  41. }
  42. type passthroughFlushTestErrorBody struct {
  43. payload []byte
  44. err error
  45. sent bool
  46. }
  47. func (r *passthroughFlushTestErrorBody) Read(p []byte) (int, error) {
  48. if !r.sent {
  49. r.sent = true
  50. return copy(p, r.payload), nil
  51. }
  52. return 0, r.err
  53. }
  54. func (r *passthroughFlushTestErrorBody) Close() error { return nil }
  55. func runPassthroughFlushTest(
  56. t *testing.T,
  57. body io.ReadCloser,
  58. failAfterWrites int,
  59. setups ...func(*gin.Context),
  60. ) (*openaiStreamingResultPassthrough, *httptest.ResponseRecorder, *passthroughFlushTestWriter, error) {
  61. t.Helper()
  62. gin.SetMode(gin.TestMode)
  63. recorder := httptest.NewRecorder()
  64. c, _ := gin.CreateTestContext(recorder)
  65. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  66. writer := &passthroughFlushTestWriter{
  67. ResponseWriter: c.Writer,
  68. recorder: recorder,
  69. failAfterWrites: failAfterWrites,
  70. }
  71. c.Writer = writer
  72. for _, setup := range setups {
  73. setup(c)
  74. }
  75. svc := &OpenAIGatewayService{cfg: &config.Config{
  76. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  77. }}
  78. resp := &http.Response{
  79. StatusCode: http.StatusOK,
  80. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  81. Body: body,
  82. }
  83. result, err := svc.handleStreamingResponsePassthrough(
  84. context.Background(),
  85. resp,
  86. c,
  87. &Account{ID: 1, Platform: PlatformOpenAI, Name: "flush-test"},
  88. time.Now(),
  89. "",
  90. "",
  91. )
  92. return result, recorder, writer, err
  93. }
  94. func TestOpenAIStreamingPassthroughFlushesAtCompleteEventBoundaries(t *testing.T) {
  95. firstEvent := "event: response.output_text.delta\n" +
  96. "id: event-1\n" +
  97. `data: {"type":"response.output_text.delta","delta":"hello"}` + "\n\n"
  98. heartbeat := ": keepalive\n\n"
  99. terminalEvent := "event: response.completed\n" +
  100. `data: {"type":"response.completed","response":{"id":"resp_flush","usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}` + "\n\n"
  101. upstream := firstEvent + heartbeat + terminalEvent
  102. result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
  103. require.NoError(t, err)
  104. require.NotNil(t, result)
  105. require.Equal(t, upstream, recorder.Body.String())
  106. require.Equal(t, []int{
  107. len(firstEvent),
  108. len(firstEvent) + len(heartbeat),
  109. len(upstream),
  110. }, writer.flushBodyLengths)
  111. require.Equal(t, 3, result.usage.InputTokens)
  112. require.Equal(t, 2, result.usage.OutputTokens)
  113. }
  114. func TestOpenAIStreamingPassthroughKeepsPreamblePendingUntilFirstOutputBoundary(t *testing.T) {
  115. preamble := "event: response.created\n" +
  116. `data: {"type":"response.created","response":{"id":"resp_pending"}}` + "\n\n" +
  117. ": waiting\n\n"
  118. firstOutput := `data: {"type":"response.output_text.delta","delta":"ready"}` + "\n\n"
  119. terminalEvent := `data: {"type":"response.completed","response":{"id":"resp_pending","usage":{"input_tokens":4,"output_tokens":1,"total_tokens":5}}}` + "\n\n"
  120. upstream := preamble + firstOutput + terminalEvent
  121. _, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
  122. require.NoError(t, err)
  123. require.Equal(t, upstream, recorder.Body.String())
  124. require.Equal(t, []int{
  125. len(preamble) + len(firstOutput),
  126. len(upstream),
  127. }, writer.flushBodyLengths)
  128. }
  129. func TestOpenAIStreamingPassthroughFlushesTerminalEventAtEOFWithoutBlankLine(t *testing.T) {
  130. upstream := "event: response.completed\n" +
  131. `data: {"type":"response.completed","response":{"id":"resp_eof","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`
  132. wantBody := upstream + "\n"
  133. result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
  134. require.NoError(t, err)
  135. require.NotNil(t, result)
  136. require.Equal(t, wantBody, recorder.Body.String())
  137. require.Equal(t, []int{len(wantBody)}, writer.flushBodyLengths)
  138. require.Equal(t, 5, result.usage.InputTokens)
  139. require.Equal(t, 2, result.usage.OutputTokens)
  140. }
  141. func TestOpenAIStreamingPassthroughFailedBeforeOutputCanStillFailOverWithoutFlush(t *testing.T) {
  142. upstream := "event: response.created\n" +
  143. `data: {"type":"response.created","response":{"id":"resp_failover"}}` + "\n\n" +
  144. "event: response.failed\n" +
  145. `data: {"type":"response.failed","error":{"code":"server_error","message":"upstream processing failed"}}` + "\n\n"
  146. _, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
  147. require.Error(t, err)
  148. var failoverErr *UpstreamFailoverError
  149. require.ErrorAs(t, err, &failoverErr)
  150. require.Empty(t, recorder.Body.String())
  151. require.Empty(t, writer.flushBodyLengths)
  152. }
  153. func TestOpenAIStreamingPassthroughNonRetryableFailedBeforeOutputFlushesAtBoundary(t *testing.T) {
  154. upstream := "event: response.failed\n" +
  155. `data: {"type":"response.failed","error":{"code":"content_policy","message":"request blocked by policy"},"usage":{"input_tokens":6,"output_tokens":0,"total_tokens":6}}` + "\n\n"
  156. result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
  157. require.Error(t, err)
  158. var failoverErr *UpstreamFailoverError
  159. require.False(t, errors.As(err, &failoverErr))
  160. require.NotNil(t, result)
  161. require.Equal(t, upstream, recorder.Body.String())
  162. require.Equal(t, []int{len(upstream)}, writer.flushBodyLengths)
  163. require.Equal(t, 6, result.usage.InputTokens)
  164. require.Zero(t, result.usage.OutputTokens)
  165. }
  166. func TestOpenAIStreamingPassthroughBareErrorTerminatesBeforeDone(t *testing.T) {
  167. errorEvent := "event: error\n" +
  168. `data: {"type":"error","error":{"code":"content_policy","message":"request blocked by policy"},"usage":{"input_tokens":6,"output_tokens":0,"total_tokens":6}}` + "\n\n"
  169. upstream := errorEvent + "data: [DONE]\n\n"
  170. result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
  171. require.Error(t, err)
  172. var failoverErr *UpstreamFailoverError
  173. require.False(t, errors.As(err, &failoverErr))
  174. require.NotNil(t, result)
  175. body := recorder.Body.String()
  176. require.NotContains(t, body, `"type":"error"`)
  177. require.Equal(t, 1, strings.Count(body, `"type":"response.failed"`))
  178. require.Contains(t, body, `"status":"failed"`)
  179. require.NotContains(t, body, "[DONE]")
  180. require.Equal(t, []int{len(body)}, writer.flushBodyLengths)
  181. require.Equal(t, 6, result.usage.InputTokens)
  182. require.Zero(t, result.usage.OutputTokens)
  183. }
  184. func TestOpenAIStreamingPassthroughBareErrorDrainsAuthoritativeFailedUsage(t *testing.T) {
  185. errorEvent := "event: error\n" +
  186. `data: {"type":"error","error":{"code":"content_policy","message":"request blocked by policy"}}` + "\n\n"
  187. failedEvent := "event: response.failed\n" +
  188. `data: {"type":"response.failed","response":{"id":"resp_failed","usage":{"input_tokens":9,"output_tokens":2},"error":{"code":"content_policy","message":"request blocked by policy"}}}` + "\n\n"
  189. upstream := errorEvent + failedEvent + "data: [DONE]\n\n"
  190. result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
  191. require.Error(t, err)
  192. require.NotNil(t, result)
  193. body := recorder.Body.String()
  194. require.NotContains(t, body, `"type":"error"`)
  195. require.Equal(t, 1, strings.Count(body, `"type":"response.failed"`))
  196. require.Contains(t, body, `"id":"resp_failed"`)
  197. require.NotContains(t, body, "[DONE]")
  198. require.Equal(t, []int{len(body)}, writer.flushBodyLengths)
  199. require.Equal(t, 9, result.usage.InputTokens)
  200. require.Equal(t, 2, result.usage.OutputTokens)
  201. }
  202. func TestOpenAIStreamingPassthroughFailedAfterOutputFlushesAtBoundaryAndKeepsUsage(t *testing.T) {
  203. firstOutput := `data: {"type":"response.output_text.delta","delta":"partial"}` + "\n\n"
  204. failedEvent := "event: response.failed\n" +
  205. `data: {"type":"response.failed","error":{"code":"server_error","message":"upstream processing failed"},"usage":{"input_tokens":7,"output_tokens":2,"total_tokens":9}}` + "\n\n"
  206. upstream := firstOutput + failedEvent
  207. result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
  208. require.Error(t, err)
  209. var failoverErr *UpstreamFailoverError
  210. require.False(t, errors.As(err, &failoverErr))
  211. require.NotNil(t, result)
  212. require.Equal(t, upstream, recorder.Body.String())
  213. require.Equal(t, []int{len(firstOutput), len(upstream)}, writer.flushBodyLengths)
  214. require.Equal(t, 7, result.usage.InputTokens)
  215. require.Equal(t, 2, result.usage.OutputTokens)
  216. }
  217. func TestOpenAIStreamingPassthroughClientDisconnectStillDrainsTerminalUsage(t *testing.T) {
  218. firstOutput := `data: {"type":"response.output_text.delta","delta":"partial"}` + "\n\n"
  219. terminalEvent := `data: {"type":"response.completed","response":{"id":"resp_drain","usage":{"input_tokens":11,"output_tokens":4,"total_tokens":15}}}` + "\n\n"
  220. result, recorder, writer, err := runPassthroughFlushTest(
  221. t,
  222. io.NopCloser(strings.NewReader(firstOutput+terminalEvent)),
  223. 2,
  224. )
  225. require.NoError(t, err)
  226. require.NotNil(t, result)
  227. require.Equal(t, firstOutput, recorder.Body.String())
  228. require.Equal(t, []int{len(firstOutput)}, writer.flushBodyLengths)
  229. require.Equal(t, 1, writer.failedWrites)
  230. require.Equal(t, 11, result.usage.InputTokens)
  231. require.Equal(t, 4, result.usage.OutputTokens)
  232. }
  233. func TestOpenAIStreamingPassthroughScannerErrorFlushesWrittenResidual(t *testing.T) {
  234. upstream := []byte(`data: {"type":"response.output_text.delta","delta":"partial"}`)
  235. readErr := errors.New("upstream read failed")
  236. _, recorder, writer, err := runPassthroughFlushTest(t, &passthroughFlushTestErrorBody{
  237. payload: upstream,
  238. err: readErr,
  239. }, -1)
  240. require.ErrorIs(t, err, readErr)
  241. wantBody := string(upstream) + "\n"
  242. require.Equal(t, wantBody, recorder.Body.String())
  243. require.Equal(t, []int{len(wantBody)}, writer.flushBodyLengths)
  244. }
  245. func TestOpenAIStreamingPassthroughNamespaceRestoreErrorFlushesWrittenResidualOnce(t *testing.T) {
  246. writtenPrefix := `data: {"type":"response.output_text.delta","delta":"prefix"}` + "\n"
  247. overflowData := `data: {"type":"response.output_text.delta","delta":"not-written","overflow":1e1000}`
  248. _, recorder, writer, err := runPassthroughFlushTest(
  249. t,
  250. io.NopCloser(strings.NewReader(writtenPrefix+overflowData)),
  251. -1,
  252. func(c *gin.Context) {
  253. setOpenAIResponsesNamespaceNames(c, map[string]apicompat.ResponsesNamespaceName{
  254. "collaboration__spawn_agent": {Namespace: "collaboration", Name: "spawn_agent"},
  255. })
  256. },
  257. )
  258. require.ErrorContains(t, err, "restore OpenAI passthrough namespace response")
  259. require.Equal(t, writtenPrefix, recorder.Body.String())
  260. require.Equal(t, []int{len(writtenPrefix)}, writer.flushBodyLengths)
  261. }
  262. func TestOpenAIStreamingPassthroughBlankWriteFailureDoesNotFlushAndStillDrainsUsage(t *testing.T) {
  263. writtenDataLine := `data: {"type":"response.output_text.delta","delta":"partial"}` + "\n"
  264. terminalEvent := `data: {"type":"response.completed","response":{"id":"resp_blank_failure","usage":{"input_tokens":13,"output_tokens":5,"total_tokens":18}}}` + "\n\n"
  265. result, recorder, writer, err := runPassthroughFlushTest(
  266. t,
  267. io.NopCloser(strings.NewReader(writtenDataLine+"\n"+terminalEvent)),
  268. 1,
  269. )
  270. require.NoError(t, err)
  271. require.NotNil(t, result)
  272. require.Equal(t, writtenDataLine, recorder.Body.String())
  273. require.Empty(t, writer.flushBodyLengths)
  274. require.Equal(t, 1, writer.successfulWrites)
  275. require.Equal(t, 1, writer.failedWrites)
  276. require.Equal(t, 13, result.usage.InputTokens)
  277. require.Equal(t, 5, result.usage.OutputTokens)
  278. }