mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #7276 from cyhhao/fix/openai-keepalive-semantic-output
fix(openai): keep transport heartbeats outside semantic output
This commit is contained in:
@@ -125,6 +125,8 @@ func TestOpenAIStreamErrorFrameDoesNotStartClientOutput(t *testing.T) {
|
||||
{`{"type":"response.failed","response":{"error":{"code":"server_is_overloaded"}}}`, "response.failed", false},
|
||||
{`{"type":"response.created","response":{"id":"resp_1"}}`, "response.created", false},
|
||||
{`{"type":"response.in_progress","response":{"id":"resp_1"}}`, "response.in_progress", false},
|
||||
{`{"type":"keepalive"}`, "keepalive", false},
|
||||
{`{"type":"unknown_event"}`, "unknown_event", true},
|
||||
{`{"type":"response.output_item.added","item":{"type":"reasoning","summary":[]}}`, "response.output_item.added", false},
|
||||
{`{"type":"response.output_item.added","item":{"type":"reasoning","encrypted_content":"ciphertext"}}`, "response.output_item.added", true},
|
||||
{`{"type":"response.reasoning_summary_part.added","part":{"type":"summary_text","text":""}}`, "response.reasoning_summary_part.added", false},
|
||||
@@ -138,7 +140,7 @@ func TestOpenAIStreamErrorFrameDoesNotStartClientOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIStreamMetadataPreambleAndMessageOnlyOverloadFailOver(t *testing.T) {
|
||||
func TestOpenAIStreamMetadataAndKeepaliveBeforeOverloadFailOver(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
largeMetadata := strings.Repeat("x", 16*1024)
|
||||
stream := strings.Join([]string{
|
||||
@@ -151,6 +153,11 @@ func TestOpenAIStreamMetadataPreambleAndMessageOnlyOverloadFailOver(t *testing.T
|
||||
"event: response.reasoning_summary_part.added",
|
||||
`data: {"type":"response.reasoning_summary_part.added","part":{"type":"summary_text","text":""}}`,
|
||||
"",
|
||||
"event: keepalive",
|
||||
`data: {"type":"keepalive"}`,
|
||||
"",
|
||||
`data: {"type":"keepalive"}`,
|
||||
"",
|
||||
"event: error",
|
||||
`data: {"type":"error","error":{"type":"service_unavailable_error","message":"Our servers are currently overloaded. Please try again later."}}`,
|
||||
"",
|
||||
@@ -203,6 +210,55 @@ func TestOpenAIStreamMetadataPreambleAndMessageOnlyOverloadFailOver(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIStreamKeepaliveOnlyFailureDoesNotRecordFirstToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, passthrough := range []bool{false, true} {
|
||||
name := "native"
|
||||
if passthrough {
|
||||
name = "passthrough"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
}}}
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(strings.Join([]string{
|
||||
`data: {"type":"response.created","response":{"id":"resp_heartbeat"}}`,
|
||||
"",
|
||||
`data: {"type":"keepalive"}`,
|
||||
"",
|
||||
`data: {"type":"response.failed","response":{"id":"resp_heartbeat","status":"failed","error":{"code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}}}`,
|
||||
"",
|
||||
"",
|
||||
}, "\n"))),
|
||||
}
|
||||
account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
var err error
|
||||
if passthrough {
|
||||
var result *openaiStreamingResultPassthrough
|
||||
result, err = svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "model", "model")
|
||||
require.NotNil(t, result)
|
||||
require.Nil(t, result.firstTokenMs)
|
||||
} else {
|
||||
var result *openaiStreamingResult
|
||||
result, err = svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "model", "model")
|
||||
require.NotNil(t, result)
|
||||
require.Nil(t, result.firstTokenMs)
|
||||
}
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.True(t, failoverErr.RetryableOnSameAccount)
|
||||
require.True(t, failoverErr.RequestScopedTransient)
|
||||
require.Empty(t, rec.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 回归用例(真实上游降载序列):created → in_progress → error 帧 → response.failed。
|
||||
// 期望仍然走 pre-output failover(同账号重试 + 请求级瞬时标记),且不向客户端写出任何字节。
|
||||
func TestOpenAIStreamCapacityShedErrorFramePrecedingFailedStillFailsOver(t *testing.T) {
|
||||
|
||||
@@ -1077,9 +1077,10 @@ func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool {
|
||||
return OpenAICompactKeepaliveAdjustedWrittenSize(c) >= 0
|
||||
}
|
||||
|
||||
func openAIStreamEventIsPreamble(eventType string) bool {
|
||||
// Lifecycle metadata and transport heartbeats are not model output.
|
||||
func openAIStreamEventIsMetadata(eventType string) bool {
|
||||
switch strings.TrimSpace(eventType) {
|
||||
case "response.created", "response.in_progress":
|
||||
case "response.created", "response.in_progress", "keepalive":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1184,7 +1185,7 @@ func openAIStreamDataStartsClientOutput(data, eventType string) bool {
|
||||
case "response.output_item.added", "response.content_part.added", "response.reasoning_summary_part.added":
|
||||
return openAIStreamAddedEventStartsClientOutput([]byte(trimmed), eventType)
|
||||
}
|
||||
return !openAIStreamEventIsPreamble(eventType)
|
||||
return !openAIStreamEventIsMetadata(eventType)
|
||||
}
|
||||
|
||||
func openAIStreamItemHasVisibleOutput(item gjson.Result) bool {
|
||||
@@ -1262,7 +1263,7 @@ func openAIStreamDataStartsSemanticTTFT(data, eventType string) bool {
|
||||
payload := []byte(trimmed)
|
||||
return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload))
|
||||
default:
|
||||
return !openAIStreamEventIsPreamble(eventType)
|
||||
return !openAIStreamEventIsMetadata(eventType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestOpenAIResponsesTTFTStartsAtCompletedImage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAINativeMetadataDoesNotDisarmFirstOutputTimeout(t *testing.T) {
|
||||
func TestOpenAINativeMetadataAndKeepaliveDoNotDisarmFirstOutputTimeout(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
@@ -84,6 +84,7 @@ func TestOpenAINativeMetadataDoesNotDisarmFirstOutputTimeout(t *testing.T) {
|
||||
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")
|
||||
_, _ = io.WriteString(writer, "data: {\"type\":\"keepalive\"}\n\n")
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
}()
|
||||
|
||||
|
||||
@@ -835,7 +835,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
s.handleGrokAccountUpstreamError(ctx, account, statusCode, resp.Header, upstreamMessage)
|
||||
}
|
||||
}
|
||||
if !wroteDownstream && shouldFailover && (turn == 1 || statusCode == http.StatusTooManyRequests) {
|
||||
// A disconnected client needs this attempt drained for usage, not replayed,
|
||||
// even when only non-semantic heartbeats were delivered.
|
||||
if !clientDisconnected && !wroteDownstream && shouldFailover && (turn == 1 || statusCode == http.StatusTooManyRequests) {
|
||||
if account.Platform == PlatformGrok {
|
||||
return nil, newOpenAIUpstreamFailoverError(statusCode, resp.Header, upstreamMessage, errMessage, false)
|
||||
}
|
||||
@@ -873,11 +875,12 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
}
|
||||
}
|
||||
if !clientDisconnected && !suppressClientMessage {
|
||||
isKeepalive := eventType == "keepalive"
|
||||
stageBeforeSemanticOutput := turn == 1 && account.Platform == PlatformOpenAI && !wroteDownstream
|
||||
commitStagedMessages := !stageBeforeSemanticOutput ||
|
||||
openAIStreamDataStartsClientOutput(string(clientMessage), eventType) ||
|
||||
isOpenAIWSTerminalEvent(eventType)
|
||||
if stageBeforeSemanticOutput && !commitStagedMessages {
|
||||
if stageBeforeSemanticOutput && !commitStagedMessages && !isKeepalive {
|
||||
if pendingClientMessageBytes+int64(len(clientMessage)) > openAIFirstOutputStageMaxBytes {
|
||||
return nil, s.newOpenAIStreamFailoverError(
|
||||
c,
|
||||
@@ -892,9 +895,15 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
pendingClientMessages = append(pendingClientMessages, append([]byte(nil), clientMessage...))
|
||||
pendingClientMessageBytes += int64(len(clientMessage))
|
||||
} else {
|
||||
messages := append(pendingClientMessages, clientMessage)
|
||||
pendingClientMessages = nil
|
||||
pendingClientMessageBytes = 0
|
||||
// Keep the client connection alive without committing this attempt
|
||||
// or exposing its staged lifecycle metadata.
|
||||
var messages [][]byte
|
||||
if !isKeepalive {
|
||||
messages = pendingClientMessages
|
||||
pendingClientMessages = nil
|
||||
pendingClientMessageBytes = 0
|
||||
}
|
||||
messages = append(messages, clientMessage)
|
||||
for _, message := range messages {
|
||||
if err := writeClientMessage(message); err != nil {
|
||||
if isOpenAIWSClientDisconnectError(err) {
|
||||
@@ -915,7 +924,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
wroteDownstream,
|
||||
)
|
||||
}
|
||||
wroteDownstream = true
|
||||
if !isKeepalive {
|
||||
wroteDownstream = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -966,7 +977,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
streamErr := fmt.Errorf("read upstream http bridge stream: %w", err)
|
||||
if turn == 1 && !wroteDownstream {
|
||||
if turn == 1 && !clientDisconnected && !wroteDownstream {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, streamErr, true)
|
||||
}
|
||||
return resultWithUsage(), streamErr
|
||||
@@ -975,7 +986,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
if sawDone {
|
||||
terminalErr = errors.New("upstream http bridge stream sent [DONE] before terminal event")
|
||||
}
|
||||
if turn == 1 && !wroteDownstream {
|
||||
if turn == 1 && !clientDisconnected && !wroteDownstream {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, terminalErr, true)
|
||||
}
|
||||
return resultWithUsage(), terminalErr
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
@@ -1186,20 +1187,41 @@ func TestProxyOpenAIWSHTTPBridgeTurnBareErrorFollowedByCompletedUsesCompleted(t
|
||||
require.Equal(t, "response.completed", gjson.GetBytes(writes[1], "type").String())
|
||||
}
|
||||
|
||||
func TestProxyOpenAIWSHTTPBridgeTurnStagesMetadataBeforeCapacityFailover(t *testing.T) {
|
||||
func TestProxyOpenAIWSHTTPBridgeTurnStagesMetadataAndRelaysKeepaliveBeforeCapacityFailover(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := strings.Join([]string{
|
||||
preamble := strings.Join([]string{
|
||||
`data: {"type":"response.created","response":{"id":"resp_shed"}}`,
|
||||
"",
|
||||
`data: {"type":"response.in_progress","response":{"id":"resp_shed"}}`,
|
||||
"",
|
||||
`data: {"type":"response.failed","response":{"id":"resp_shed","status":"failed","error":{"message":"Our servers are currently overloaded. Please try again later."}}}`,
|
||||
`data: {"type":"keepalive"}`,
|
||||
"",
|
||||
"",
|
||||
}, "\n")
|
||||
reader, writer := io.Pipe()
|
||||
defer func() { _ = reader.Close() }()
|
||||
defer func() { _ = writer.Close() }()
|
||||
keepaliveReceived := make(chan struct{}, 1)
|
||||
writerDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, writeErr := io.WriteString(writer, preamble)
|
||||
if writeErr == nil {
|
||||
// The upstream stays idle until the heartbeat reaches the client.
|
||||
// Flushing it only with later output or EOF must not pass this test.
|
||||
select {
|
||||
case <-keepaliveReceived:
|
||||
_, writeErr = io.WriteString(writer, "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_shed\",\"status\":\"failed\",\"error\":{\"message\":\"Our servers are currently overloaded. Please try again later.\"}}}\n\n")
|
||||
case <-time.After(2 * time.Second):
|
||||
writeErr = errors.New("keepalive was not forwarded while upstream was idle")
|
||||
}
|
||||
}
|
||||
_ = writer.CloseWithError(writeErr)
|
||||
writerDone <- writeErr
|
||||
}()
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"X-Request-Id": []string{"rid-ws-bridge-capacity"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Body: reader,
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1}
|
||||
@@ -1214,16 +1236,21 @@ func TestProxyOpenAIWSHTTPBridgeTurnStagesMetadataBeforeCapacityFailover(t *test
|
||||
"gpt-5", "", "", "", "", 1,
|
||||
func(message []byte) error {
|
||||
writes = append(writes, append([]byte(nil), message...))
|
||||
if gjson.GetBytes(message, "type").String() == "keepalive" {
|
||||
keepaliveReceived <- struct{}{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, <-writerDone)
|
||||
require.Nil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.True(t, failoverErr.RetryableOnSameAccount)
|
||||
require.True(t, failoverErr.RequestScopedTransient)
|
||||
require.Empty(t, writes)
|
||||
require.Len(t, writes, 1)
|
||||
require.JSONEq(t, `{"type":"keepalive"}`, string(writes[0]))
|
||||
}
|
||||
|
||||
func TestProxyOpenAIWSHTTPBridgeTurnDoesNotReplayCapacityAfterSemanticOutput(t *testing.T) {
|
||||
@@ -1233,8 +1260,12 @@ func TestProxyOpenAIWSHTTPBridgeTurnDoesNotReplayCapacityAfterSemanticOutput(t *
|
||||
body := strings.Join([]string{
|
||||
`data: {"type":"response.created","response":{"id":"resp_partial"}}`,
|
||||
"",
|
||||
`data: {"type":"keepalive"}`,
|
||||
"",
|
||||
`data: {"type":"response.output_text.delta","delta":"partial"}`,
|
||||
"",
|
||||
`data: {"type":"keepalive"}`,
|
||||
"",
|
||||
`data: {"type":"response.failed","response":{"id":"resp_partial","status":"failed","error":{"code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
@@ -1262,15 +1293,154 @@ func TestProxyOpenAIWSHTTPBridgeTurnDoesNotReplayCapacityAfterSemanticOutput(t *
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, writes, 3)
|
||||
require.Len(t, writes, 5)
|
||||
require.Equal(t, "keepalive", gjson.GetBytes(writes[0], "type").String())
|
||||
require.Equal(t, "response.created", gjson.GetBytes(writes[1], "type").String())
|
||||
require.Equal(t, "response.output_text.delta", gjson.GetBytes(writes[2], "type").String())
|
||||
require.Equal(t, "keepalive", gjson.GetBytes(writes[3], "type").String())
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.False(t, errors.As(err, &failoverErr))
|
||||
require.Contains(t, string(writes[2]), `"code":"server_error"`)
|
||||
require.NotContains(t, string(writes[2]), "server_is_overloaded")
|
||||
require.Contains(t, string(writes[4]), `"code":"server_error"`)
|
||||
require.NotContains(t, string(writes[4]), "server_is_overloaded")
|
||||
require.True(t, logSink.ContainsMessage("gateway.failover_suppressed_after_semantic_output"))
|
||||
require.True(t, logSink.ContainsFieldValue("path", "ws_http_bridge"))
|
||||
}
|
||||
|
||||
func TestProxyOpenAIWSHTTPBridgeTurnKeepaliveWriteFailure(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
writeErr error
|
||||
wantDrain bool
|
||||
}{
|
||||
{name: "write error", writeErr: errors.New("downstream write failed")},
|
||||
{name: "client disconnect", writeErr: io.EOF, wantDrain: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`data: {"type":"response.created","response":{"id":"resp_disconnect"}}`,
|
||||
"",
|
||||
`data: {"type":"keepalive"}`,
|
||||
"",
|
||||
`data: {"type":"response.completed","response":{"id":"resp_disconnect","usage":{"input_tokens":3,"output_tokens":2}}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{ID: 14, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1}
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
|
||||
payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`)
|
||||
var writes [][]byte
|
||||
|
||||
result, err := svc.proxyOpenAIWSHTTPBridgeTurn(
|
||||
context.Background(), c, account, "sk-test", payload, len(payload),
|
||||
"gpt-5", "", "", "", "", 1,
|
||||
func(message []byte) error {
|
||||
writes = append(writes, append([]byte(nil), message...))
|
||||
return tc.writeErr
|
||||
},
|
||||
)
|
||||
|
||||
require.Len(t, writes, 1)
|
||||
require.JSONEq(t, `{"type":"keepalive"}`, string(writes[0]))
|
||||
if tc.wantDrain {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 3, result.Usage.InputTokens)
|
||||
require.Equal(t, 2, result.Usage.OutputTokens)
|
||||
} else {
|
||||
require.Nil(t, result)
|
||||
require.ErrorIs(t, err, tc.writeErr)
|
||||
var turnErr *openAIWSIngressTurnError
|
||||
require.ErrorAs(t, err, &turnErr)
|
||||
require.Equal(t, "write_client", turnErr.stage)
|
||||
require.False(t, turnErr.wroteDownstream)
|
||||
require.False(t, isOpenAIWSIngressTurnRetryable(err))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyOpenAIWSHTTPBridgeTurnDisconnectedBeforeOutputDoesNotFailOver(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tail string
|
||||
readErr error
|
||||
wantTerminal bool
|
||||
}{
|
||||
{
|
||||
name: "overload followed by failed usage",
|
||||
tail: "data: {\"type\":\"error\",\"error\":{\"code\":\"server_is_overloaded\",\"message\":\"overloaded\"}}\n\n" +
|
||||
"data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_disconnect\",\"status\":\"failed\",\"error\":{\"code\":\"server_is_overloaded\",\"message\":\"overloaded\"},\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}}\n\n",
|
||||
wantTerminal: true,
|
||||
},
|
||||
{name: "upstream read failure", readErr: io.ErrUnexpectedEOF},
|
||||
{name: "upstream EOF without terminal"},
|
||||
{name: "done without terminal", tail: "data: [DONE]\n\n"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
preamble := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_disconnect\"}}\n\n" +
|
||||
"data: {\"type\":\"keepalive\"}\n\n" +
|
||||
"data: {\"type\":\"keepalive\"}\n\n"
|
||||
var reader io.Reader = strings.NewReader(preamble + tc.tail)
|
||||
if tc.readErr != nil {
|
||||
reader = io.MultiReader(reader, iotest.ErrReader(tc.readErr))
|
||||
}
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(reader),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{ID: 15, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1}
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
|
||||
payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`)
|
||||
var writes [][]byte
|
||||
|
||||
result, err := svc.proxyOpenAIWSHTTPBridgeTurn(
|
||||
context.Background(), c, account, "sk-test", payload, len(payload),
|
||||
"gpt-5", "", "", "", "", 1,
|
||||
func(message []byte) error {
|
||||
writes = append(writes, append([]byte(nil), message...))
|
||||
if len(writes) == 1 {
|
||||
return nil
|
||||
}
|
||||
return io.EOF
|
||||
},
|
||||
)
|
||||
|
||||
require.NotNil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.False(t, errors.As(err, &failoverErr))
|
||||
require.Len(t, writes, 2)
|
||||
for _, message := range writes {
|
||||
require.JSONEq(t, `{"type":"keepalive"}`, string(message))
|
||||
}
|
||||
require.Nil(t, result.FirstTokenMs)
|
||||
if tc.wantTerminal {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "response.failed", result.UpstreamTerminalEvent)
|
||||
require.Equal(t, 3, result.Usage.InputTokens)
|
||||
require.Equal(t, 2, result.Usage.OutputTokens)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
if tc.readErr != nil {
|
||||
require.ErrorIs(t, err, tc.readErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyOpenAIWSHTTPBridgeTurnRequiresTerminalEvent(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user