mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-06 08:49:24 +00: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:
5 files changed
+258
-19
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
|
||||||
|
// or exposing its staged lifecycle metadata.
|
||||||
|
var messages [][]byte
|
||||||
|
if !isKeepalive {
|
||||||
|
messages = pendingClientMessages
|
||||||
pendingClientMessages = nil
|
pendingClientMessages = nil
|
||||||
pendingClientMessageBytes = 0
|
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,10 +924,12 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
|||||||
wroteDownstream,
|
wroteDownstream,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if !isKeepalive {
|
||||||
wroteDownstream = true
|
wroteDownstream = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if !clientDisconnected && !suppressClientMessage {
|
if !clientDisconnected && !suppressClientMessage {
|
||||||
markOpenAIWSClientVisibleFailure(c, eventType, upstreamMessage)
|
markOpenAIWSClientVisibleFailure(c, eventType, upstreamMessage)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user