openai_visible_ttft_test.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. package service
  2. import (
  3. "context"
  4. "io"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. )
  13. func TestOpenAIVisibleOutputClassification(t *testing.T) {
  14. tests := []struct {
  15. name string
  16. data string
  17. eventType string
  18. want bool
  19. }{
  20. {name: "keepalive", data: `{"type":"keepalive"}`, want: false},
  21. {name: "created", data: `{"type":"response.created"}`, want: false},
  22. {name: "empty output item", data: `{"type":"response.output_item.added","item":{"id":"item_test","type":"reasoning","summary":[]}}`, want: false},
  23. {name: "empty delta", data: `{"type":"response.output_text.delta","delta":""}`, want: false},
  24. {name: "text delta", data: `{"type":"response.output_text.delta","delta":"test output"}`, want: true},
  25. {name: "tool arguments", data: `{"type":"response.function_call_arguments.delta","delta":"{}"}`, want: true},
  26. {name: "partial image", data: `{"type":"response.image_generation_call.partial_image","partial_image_b64":"dGVzdA=="}`, want: true},
  27. {name: "completed image item", data: `{"type":"response.output_item.done","item":{"id":"item_test","type":"image_generation_call","result":"dGVzdA=="}}`, want: true},
  28. {name: "empty completed", data: `{"type":"response.completed","response":{"id":"resp_test","output":[]}}`, want: false},
  29. {name: "completed with output usage only", data: `{"type":"response.completed","response":{"id":"resp_test","usage":{"input_tokens":1,"output_tokens":2}}}`, want: false},
  30. {name: "completed with text", data: `{"type":"response.completed","response":{"id":"resp_test","output":[{"type":"message","content":[{"type":"output_text","text":"test output"}]}]}}`, want: true},
  31. {name: "done marker", data: `[DONE]`, want: false},
  32. }
  33. for _, tt := range tests {
  34. t.Run(tt.name, func(t *testing.T) {
  35. require.Equal(t, tt.want, openAIStreamDataStartsVisibleOutput(tt.data, tt.eventType))
  36. })
  37. }
  38. }
  39. func TestOpenAIResponsesTTFTStartsAtVisibleOutput(t *testing.T) {
  40. for _, passthrough := range []bool{false, true} {
  41. name := "native"
  42. if passthrough {
  43. name = "passthrough"
  44. }
  45. t.Run(name, func(t *testing.T) {
  46. result := runSyntheticVisibleTTFTStream(t, passthrough, 120*time.Millisecond, 0, OpenAITTFTModeVisible,
  47. `{"type":"response.output_text.delta","delta":"test output"}`)
  48. require.NotNil(t, result.firstTokenMs)
  49. require.GreaterOrEqual(t, *result.firstTokenMs, 100)
  50. })
  51. }
  52. }
  53. func TestOpenAIResponsesTTFTStartsAtCompletedImage(t *testing.T) {
  54. for _, passthrough := range []bool{false, true} {
  55. name := "native"
  56. if passthrough {
  57. name = "passthrough"
  58. }
  59. t.Run(name, func(t *testing.T) {
  60. result := runSyntheticVisibleTTFTStream(t, passthrough, 120*time.Millisecond, 0, OpenAITTFTModeVisible,
  61. `{"type":"response.output_item.done","item":{"id":"item_test","type":"image_generation_call","result":"dGVzdA=="}}`)
  62. require.NotNil(t, result.firstTokenMs)
  63. require.GreaterOrEqual(t, *result.firstTokenMs, 100)
  64. })
  65. }
  66. }
  67. func TestOpenAINativeMetadataDoesNotDisarmFirstOutputTimeout(t *testing.T) {
  68. gin.SetMode(gin.TestMode)
  69. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
  70. MaxLineSize: defaultMaxLineSize,
  71. OpenAIFirstOutputTimeoutSeconds: 1,
  72. }}}
  73. reader, writer := io.Pipe()
  74. writerDone := make(chan struct{})
  75. go func() {
  76. defer close(writerDone)
  77. defer func() { _ = writer.Close() }()
  78. _, _ = io.WriteString(writer, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_test\"}}\n\n")
  79. _, _ = io.WriteString(writer, "data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"item_test\",\"type\":\"reasoning\",\"summary\":[]}}\n\n")
  80. time.Sleep(1200 * time.Millisecond)
  81. }()
  82. recorder := httptest.NewRecorder()
  83. c, _ := gin.CreateTestContext(recorder)
  84. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  85. resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: reader}
  86. account := &Account{ID: 1, Name: "account_test", Platform: PlatformOpenAI}
  87. _, err := svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "test-model", "test-model")
  88. var failoverErr *UpstreamFailoverError
  89. require.ErrorAs(t, err, &failoverErr)
  90. require.True(t, failoverErr.SafeToFailoverAfterWrite)
  91. require.Empty(t, recorder.Body.String())
  92. select {
  93. case <-writerDone:
  94. case <-time.After(time.Second):
  95. t.Fatal("synthetic upstream writer did not exit")
  96. }
  97. }
  98. func TestOpenAIResponsesTTFTDefaultsToSemanticOutput(t *testing.T) {
  99. for _, passthrough := range []bool{false, true} {
  100. name := "native"
  101. if passthrough {
  102. name = "passthrough"
  103. }
  104. t.Run(name, func(t *testing.T) {
  105. result := runSyntheticVisibleTTFTStream(t, passthrough, 120*time.Millisecond, 0, "",
  106. `{"type":"response.output_text.delta","delta":"test output"}`)
  107. require.NotNil(t, result.firstTokenMs)
  108. require.Less(t, *result.firstTokenMs, 100)
  109. })
  110. }
  111. }
  112. func runSyntheticVisibleTTFTStream(t *testing.T, passthrough bool, visibleDelay time.Duration, timeoutSeconds int, ttftMode string, visibleEvent string) *openaiStreamingResult {
  113. t.Helper()
  114. gin.SetMode(gin.TestMode)
  115. mode := ttftMode
  116. if mode == "" {
  117. mode = OpenAITTFTModeSemantic
  118. }
  119. gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{openAITTFTMode: mode, expiresAt: time.Now().Add(time.Minute).UnixNano()})
  120. t.Cleanup(func() {
  121. gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{openAITTFTMode: OpenAITTFTModeSemantic, expiresAt: time.Now().Add(time.Minute).UnixNano()})
  122. })
  123. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
  124. MaxLineSize: defaultMaxLineSize,
  125. OpenAIFirstOutputTimeoutSeconds: timeoutSeconds,
  126. }}}
  127. reader, writer := io.Pipe()
  128. writerDone := make(chan struct{})
  129. go func() {
  130. defer close(writerDone)
  131. defer func() { _ = writer.Close() }()
  132. _, _ = io.WriteString(writer, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_test\"}}\n\n")
  133. _, _ = io.WriteString(writer, "data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"item_test\",\"type\":\"reasoning\",\"summary\":[]}}\n\n")
  134. time.Sleep(visibleDelay)
  135. _, _ = io.WriteString(writer, "data: "+visibleEvent+"\n\n")
  136. _, _ = io.WriteString(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
  137. }()
  138. recorder := httptest.NewRecorder()
  139. c, _ := gin.CreateTestContext(recorder)
  140. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  141. resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: reader}
  142. account := &Account{ID: 1, Name: "account_test", Platform: PlatformOpenAI}
  143. started := time.Now()
  144. var result *openaiStreamingResult
  145. var err error
  146. if passthrough {
  147. var passthroughResult *openaiStreamingResultPassthrough
  148. passthroughResult, err = svc.handleStreamingResponsePassthrough(context.Background(), resp, c, account, started, "test-model", "test-model")
  149. if passthroughResult != nil {
  150. result = &openaiStreamingResult{firstTokenMs: passthroughResult.firstTokenMs}
  151. }
  152. } else {
  153. result, err = svc.handleStreamingResponse(context.Background(), resp, c, account, started, "test-model", "test-model")
  154. }
  155. require.NoError(t, err)
  156. require.NotNil(t, result)
  157. require.Contains(t, recorder.Body.String(), `"type":"response.output_item.added"`)
  158. require.Contains(t, recorder.Body.String(), visibleEvent)
  159. select {
  160. case <-writerDone:
  161. case <-time.After(time.Second):
  162. t.Fatal("synthetic upstream writer did not exit")
  163. }
  164. return result
  165. }