| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178 |
- package service
- import (
- "context"
- "io"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- func TestOpenAIVisibleOutputClassification(t *testing.T) {
- tests := []struct {
- name string
- data string
- eventType string
- want bool
- }{
- {name: "keepalive", data: `{"type":"keepalive"}`, want: false},
- {name: "created", data: `{"type":"response.created"}`, want: false},
- {name: "empty output item", data: `{"type":"response.output_item.added","item":{"id":"item_test","type":"reasoning","summary":[]}}`, want: false},
- {name: "empty delta", data: `{"type":"response.output_text.delta","delta":""}`, want: false},
- {name: "text delta", data: `{"type":"response.output_text.delta","delta":"test output"}`, want: true},
- {name: "tool arguments", data: `{"type":"response.function_call_arguments.delta","delta":"{}"}`, want: true},
- {name: "partial image", data: `{"type":"response.image_generation_call.partial_image","partial_image_b64":"dGVzdA=="}`, want: true},
- {name: "completed image item", data: `{"type":"response.output_item.done","item":{"id":"item_test","type":"image_generation_call","result":"dGVzdA=="}}`, want: true},
- {name: "empty completed", data: `{"type":"response.completed","response":{"id":"resp_test","output":[]}}`, want: false},
- {name: "completed with output usage only", data: `{"type":"response.completed","response":{"id":"resp_test","usage":{"input_tokens":1,"output_tokens":2}}}`, want: false},
- {name: "completed with text", data: `{"type":"response.completed","response":{"id":"resp_test","output":[{"type":"message","content":[{"type":"output_text","text":"test output"}]}]}}`, want: true},
- {name: "done marker", data: `[DONE]`, want: false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- require.Equal(t, tt.want, openAIStreamDataStartsVisibleOutput(tt.data, tt.eventType))
- })
- }
- }
- func TestOpenAIResponsesTTFTStartsAtVisibleOutput(t *testing.T) {
- for _, passthrough := range []bool{false, true} {
- name := "native"
- if passthrough {
- name = "passthrough"
- }
- t.Run(name, func(t *testing.T) {
- result := runSyntheticVisibleTTFTStream(t, passthrough, 120*time.Millisecond, 0, OpenAITTFTModeVisible,
- `{"type":"response.output_text.delta","delta":"test output"}`)
- require.NotNil(t, result.firstTokenMs)
- require.GreaterOrEqual(t, *result.firstTokenMs, 100)
- })
- }
- }
- func TestOpenAIResponsesTTFTStartsAtCompletedImage(t *testing.T) {
- for _, passthrough := range []bool{false, true} {
- name := "native"
- if passthrough {
- name = "passthrough"
- }
- t.Run(name, func(t *testing.T) {
- result := runSyntheticVisibleTTFTStream(t, passthrough, 120*time.Millisecond, 0, OpenAITTFTModeVisible,
- `{"type":"response.output_item.done","item":{"id":"item_test","type":"image_generation_call","result":"dGVzdA=="}}`)
- require.NotNil(t, result.firstTokenMs)
- require.GreaterOrEqual(t, *result.firstTokenMs, 100)
- })
- }
- }
- func TestOpenAINativeMetadataDoesNotDisarmFirstOutputTimeout(t *testing.T) {
- gin.SetMode(gin.TestMode)
- svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
- MaxLineSize: defaultMaxLineSize,
- OpenAIFirstOutputTimeoutSeconds: 1,
- }}}
- reader, writer := io.Pipe()
- writerDone := make(chan struct{})
- go func() {
- defer close(writerDone)
- defer func() { _ = writer.Close() }()
- _, _ = io.WriteString(writer, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_test\"}}\n\n")
- _, _ = io.WriteString(writer, "data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"item_test\",\"type\":\"reasoning\",\"summary\":[]}}\n\n")
- time.Sleep(1200 * time.Millisecond)
- }()
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: reader}
- account := &Account{ID: 1, Name: "account_test", Platform: PlatformOpenAI}
- _, err := svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "test-model", "test-model")
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.True(t, failoverErr.SafeToFailoverAfterWrite)
- require.Empty(t, recorder.Body.String())
- select {
- case <-writerDone:
- case <-time.After(time.Second):
- t.Fatal("synthetic upstream writer did not exit")
- }
- }
- func TestOpenAIResponsesTTFTDefaultsToSemanticOutput(t *testing.T) {
- for _, passthrough := range []bool{false, true} {
- name := "native"
- if passthrough {
- name = "passthrough"
- }
- t.Run(name, func(t *testing.T) {
- result := runSyntheticVisibleTTFTStream(t, passthrough, 120*time.Millisecond, 0, "",
- `{"type":"response.output_text.delta","delta":"test output"}`)
- require.NotNil(t, result.firstTokenMs)
- require.Less(t, *result.firstTokenMs, 100)
- })
- }
- }
- func runSyntheticVisibleTTFTStream(t *testing.T, passthrough bool, visibleDelay time.Duration, timeoutSeconds int, ttftMode string, visibleEvent string) *openaiStreamingResult {
- t.Helper()
- gin.SetMode(gin.TestMode)
- mode := ttftMode
- if mode == "" {
- mode = OpenAITTFTModeSemantic
- }
- gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{openAITTFTMode: mode, expiresAt: time.Now().Add(time.Minute).UnixNano()})
- t.Cleanup(func() {
- gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{openAITTFTMode: OpenAITTFTModeSemantic, expiresAt: time.Now().Add(time.Minute).UnixNano()})
- })
- svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
- MaxLineSize: defaultMaxLineSize,
- OpenAIFirstOutputTimeoutSeconds: timeoutSeconds,
- }}}
- reader, writer := io.Pipe()
- writerDone := make(chan struct{})
- go func() {
- defer close(writerDone)
- defer func() { _ = writer.Close() }()
- _, _ = io.WriteString(writer, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_test\"}}\n\n")
- _, _ = io.WriteString(writer, "data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"item_test\",\"type\":\"reasoning\",\"summary\":[]}}\n\n")
- time.Sleep(visibleDelay)
- _, _ = io.WriteString(writer, "data: "+visibleEvent+"\n\n")
- _, _ = io.WriteString(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
- }()
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: reader}
- account := &Account{ID: 1, Name: "account_test", Platform: PlatformOpenAI}
- started := time.Now()
- var result *openaiStreamingResult
- var err error
- if passthrough {
- var passthroughResult *openaiStreamingResultPassthrough
- passthroughResult, err = svc.handleStreamingResponsePassthrough(context.Background(), resp, c, account, started, "test-model", "test-model")
- if passthroughResult != nil {
- result = &openaiStreamingResult{firstTokenMs: passthroughResult.firstTokenMs}
- }
- } else {
- result, err = svc.handleStreamingResponse(context.Background(), resp, c, account, started, "test-model", "test-model")
- }
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Contains(t, recorder.Body.String(), `"type":"response.output_item.added"`)
- require.Contains(t, recorder.Body.String(), visibleEvent)
- select {
- case <-writerDone:
- case <-time.After(time.Second):
- t.Fatal("synthetic upstream writer did not exit")
- }
- return result
- }
|