Merge pull request #7276 from cyhhao/fix/openai-keepalive-semantic-output

fix(openai): keep transport heartbeats outside semantic output
This commit is contained in:
Wesley Liddick authored and GitHub committed 2026-09-22 22:00:08 +08:00
commit 13be6ca27c
5 files changed
+261 -22

No files matched your search

@@ -125,6 +125,8 @@ func TestOpenAIStreamErrorFrameDoesNotStartClientOutput(t *testing.T) {
{`{"type":"response.failed","response":{"error":{"code":"server_is_overloaded"}}}`, "response.failed", false}, {`{"type":"response.failed","response":{"error":{"code":"server_is_overloaded"}}}`, "response.failed", false},
{`{"type":"response.created","response":{"id":"resp_1"}}`, "response.created", false}, {`{"type":"response.created","response":{"id":"resp_1"}}`, "response.created", false},
{`{"type":"response.in_progress","response":{"id":"resp_1"}}`, "response.in_progress", 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","summary":[]}}`, "response.output_item.added", false},
{`{"type":"response.output_item.added","item":{"type":"reasoning","encrypted_content":"ciphertext"}}`, "response.output_item.added", true}, {`{"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}, {`{"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) gin.SetMode(gin.TestMode)
largeMetadata := strings.Repeat("x", 16*1024) largeMetadata := strings.Repeat("x", 16*1024)
stream := strings.Join([]string{ stream := strings.Join([]string{
@@ -151,6 +153,11 @@ func TestOpenAIStreamMetadataPreambleAndMessageOnlyOverloadFailOver(t *testing.T
"event: response.reasoning_summary_part.added", "event: response.reasoning_summary_part.added",
`data: {"type":"response.reasoning_summary_part.added","part":{"type":"summary_text","text":""}}`, `data: {"type":"response.reasoning_summary_part.added","part":{"type":"summary_text","text":""}}`,
"", "",
"event: keepalive",
`data: {"type":"keepalive"}`,
"",
`data: {"type":"keepalive"}`,
"",
"event: error", "event: error",
`data: {"type":"error","error":{"type":"service_unavailable_error","message":"Our servers are currently overloaded. Please try again later."}}`, `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。 // 回归用例(真实上游降载序列):created → in_progress → error 帧 → response.failed。
// 期望仍然走 pre-output failover(同账号重试 + 请求级瞬时标记),且不向客户端写出任何字节。 // 期望仍然走 pre-output failover(同账号重试 + 请求级瞬时标记),且不向客户端写出任何字节。
func TestOpenAIStreamCapacityShedErrorFramePrecedingFailedStillFailsOver(t *testing.T) { func TestOpenAIStreamCapacityShedErrorFramePrecedingFailedStillFailsOver(t *testing.T) {
@@ -1077,9 +1077,10 @@ func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool {
return OpenAICompactKeepaliveAdjustedWrittenSize(c) >= 0 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) { switch strings.TrimSpace(eventType) {
case "response.created", "response.in_progress": case "response.created", "response.in_progress", "keepalive":
return true return true
default: default:
return false 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": case "response.output_item.added", "response.content_part.added", "response.reasoning_summary_part.added":
return openAIStreamAddedEventStartsClientOutput([]byte(trimmed), eventType) return openAIStreamAddedEventStartsClientOutput([]byte(trimmed), eventType)
} }
return !openAIStreamEventIsPreamble(eventType) return !openAIStreamEventIsMetadata(eventType)
} }
func openAIStreamItemHasVisibleOutput(item gjson.Result) bool { func openAIStreamItemHasVisibleOutput(item gjson.Result) bool {
@@ -1262,7 +1263,7 @@ func openAIStreamDataStartsSemanticTTFT(data, eventType string) bool {
payload := []byte(trimmed) payload := []byte(trimmed)
return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload)) return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload))
default: 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) gin.SetMode(gin.TestMode)
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{ svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
MaxLineSize: defaultMaxLineSize, MaxLineSize: defaultMaxLineSize,
@@ -84,6 +84,7 @@ func TestOpenAINativeMetadataDoesNotDisarmFirstOutputTimeout(t *testing.T) {
defer func() { _ = writer.Close() }() defer func() { _ = writer.Close() }()
_, _ = io.WriteString(writer, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_test\"}}\n\n") _, _ = 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\":\"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) time.Sleep(1200 * time.Millisecond)
}() }()
@@ -835,7 +835,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
s.handleGrokAccountUpstreamError(ctx, account, statusCode, resp.Header, upstreamMessage) 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 { if account.Platform == PlatformGrok {
return nil, newOpenAIUpstreamFailoverError(statusCode, resp.Header, upstreamMessage, errMessage, false) return nil, newOpenAIUpstreamFailoverError(statusCode, resp.Header, upstreamMessage, errMessage, false)
} }
@@ -873,11 +875,12 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
} }
} }
if !clientDisconnected && !suppressClientMessage { if !clientDisconnected && !suppressClientMessage {
isKeepalive := eventType == "keepalive"
stageBeforeSemanticOutput := turn == 1 && account.Platform == PlatformOpenAI && !wroteDownstream stageBeforeSemanticOutput := turn == 1 && account.Platform == PlatformOpenAI && !wroteDownstream
commitStagedMessages := !stageBeforeSemanticOutput || commitStagedMessages := !stageBeforeSemanticOutput ||
openAIStreamDataStartsClientOutput(string(clientMessage), eventType) || openAIStreamDataStartsClientOutput(string(clientMessage), eventType) ||
isOpenAIWSTerminalEvent(eventType) isOpenAIWSTerminalEvent(eventType)
if stageBeforeSemanticOutput && !commitStagedMessages { if stageBeforeSemanticOutput && !commitStagedMessages && !isKeepalive {
if pendingClientMessageBytes+int64(len(clientMessage)) > openAIFirstOutputStageMaxBytes { if pendingClientMessageBytes+int64(len(clientMessage)) > openAIFirstOutputStageMaxBytes {
return nil, s.newOpenAIStreamFailoverError( return nil, s.newOpenAIStreamFailoverError(
c, c,
@@ -892,9 +895,15 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
pendingClientMessages = append(pendingClientMessages, append([]byte(nil), clientMessage...)) pendingClientMessages = append(pendingClientMessages, append([]byte(nil), clientMessage...))
pendingClientMessageBytes += int64(len(clientMessage)) pendingClientMessageBytes += int64(len(clientMessage))
} else { } else {
messages := append(pendingClientMessages, clientMessage) // Keep the client connection alive without committing this attempt
pendingClientMessages = nil // or exposing its staged lifecycle metadata.
pendingClientMessageBytes = 0 var messages [][]byte
if !isKeepalive {
messages = pendingClientMessages
pendingClientMessages = nil
pendingClientMessageBytes = 0
}
messages = append(messages, clientMessage)
for _, message := range messages { for _, message := range messages {
if err := writeClientMessage(message); err != nil { if err := writeClientMessage(message); err != nil {
if isOpenAIWSClientDisconnectError(err) { if isOpenAIWSClientDisconnectError(err) {
@@ -915,7 +924,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
wroteDownstream, wroteDownstream,
) )
} }
wroteDownstream = true if !isKeepalive {
wroteDownstream = true
}
} }
} }
} }
@@ -966,7 +977,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
streamErr := fmt.Errorf("read upstream http bridge stream: %w", err) 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 nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, streamErr, true)
} }
return resultWithUsage(), streamErr return resultWithUsage(), streamErr
@@ -975,7 +986,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
if sawDone { if sawDone {
terminalErr = errors.New("upstream http bridge stream sent [DONE] before terminal event") 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 nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, terminalErr, true)
} }
return resultWithUsage(), terminalErr return resultWithUsage(), terminalErr
@@ -9,6 +9,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"testing/iotest"
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/config" "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()) 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) gin.SetMode(gin.TestMode)
body := strings.Join([]string{ preamble := strings.Join([]string{
`data: {"type":"response.created","response":{"id":"resp_shed"}}`, `data: {"type":"response.created","response":{"id":"resp_shed"}}`,
"", "",
`data: {"type":"response.in_progress","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") }, "\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{ upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK, StatusCode: http.StatusOK,
Header: http.Header{"X-Request-Id": []string{"rid-ws-bridge-capacity"}}, 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} svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1} account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1}
@@ -1214,16 +1236,21 @@ func TestProxyOpenAIWSHTTPBridgeTurnStagesMetadataBeforeCapacityFailover(t *test
"gpt-5", "", "", "", "", 1, "gpt-5", "", "", "", "", 1,
func(message []byte) error { func(message []byte) error {
writes = append(writes, append([]byte(nil), message...)) writes = append(writes, append([]byte(nil), message...))
if gjson.GetBytes(message, "type").String() == "keepalive" {
keepaliveReceived <- struct{}{}
}
return nil return nil
}, },
) )
require.NoError(t, <-writerDone)
require.Nil(t, result) require.Nil(t, result)
var failoverErr *UpstreamFailoverError var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr) require.ErrorAs(t, err, &failoverErr)
require.True(t, failoverErr.RetryableOnSameAccount) require.True(t, failoverErr.RetryableOnSameAccount)
require.True(t, failoverErr.RequestScopedTransient) 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) { func TestProxyOpenAIWSHTTPBridgeTurnDoesNotReplayCapacityAfterSemanticOutput(t *testing.T) {
@@ -1233,8 +1260,12 @@ func TestProxyOpenAIWSHTTPBridgeTurnDoesNotReplayCapacityAfterSemanticOutput(t *
body := strings.Join([]string{ body := strings.Join([]string{
`data: {"type":"response.created","response":{"id":"resp_partial"}}`, `data: {"type":"response.created","response":{"id":"resp_partial"}}`,
"", "",
`data: {"type":"keepalive"}`,
"",
`data: {"type":"response.output_text.delta","delta":"partial"}`, `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."}}}`, `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") }, "\n")
@@ -1262,15 +1293,154 @@ func TestProxyOpenAIWSHTTPBridgeTurnDoesNotReplayCapacityAfterSemanticOutput(t *
require.NotNil(t, result) require.NotNil(t, result)
require.NoError(t, err) 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 var failoverErr *UpstreamFailoverError
require.False(t, errors.As(err, &failoverErr)) require.False(t, errors.As(err, &failoverErr))
require.Contains(t, string(writes[2]), `"code":"server_error"`) require.Contains(t, string(writes[4]), `"code":"server_error"`)
require.NotContains(t, string(writes[2]), "server_is_overloaded") require.NotContains(t, string(writes[4]), "server_is_overloaded")
require.True(t, logSink.ContainsMessage("gateway.failover_suppressed_after_semantic_output")) require.True(t, logSink.ContainsMessage("gateway.failover_suppressed_after_semantic_output"))
require.True(t, logSink.ContainsFieldValue("path", "ws_http_bridge")) 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) { func TestProxyOpenAIWSHTTPBridgeTurnRequiresTerminalEvent(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)