Merge pull request #6473 from cxx-cod/pr/openai-stream-terminal-eof

fix(openai): end streaming at terminal event instead of waiting for u…
This commit is contained in:
Wesley Liddick
2026-09-22 21:57:45 +08:00
committed by GitHub
3 changed files with 67 additions and 0 deletions
@@ -2216,6 +2216,14 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
responseFailedPending = false
failureDelivered = true
}
// Terminal 事件(response.completed / [DONE] 等)随空行完整刷出后不再等上游
// EOF:上游在 keep-alive/HTTP2 复用连接上可能拖延关闭连接(观测到 8~46s 不等),
// 空等期间只能靠 keepalive 维持,白白拉长尾延迟。usage 已在 terminal 事件中解析。
// Codex bare error 序列(error 后可能跟 response.failed 或翻盘的 completed)
// 必须继续读取,不适用提前结束。
if (sawDone || sawTerminalEvent) && line == "" && (!codexFailureTerminal || !sawBareError) {
break
}
}
ensureResponseFailedTerminal()
if err := documentScanner.Err(); err != nil {
@@ -644,3 +644,47 @@ func waitOpenAIResponseFlushSignal(t *testing.T, signal <-chan struct{}) {
t.Fatal("timed out waiting for stream signal")
}
}
// hangingOpenAISSEAfterTerminal 模拟上游在发完 terminal 事件后拖延关闭连接
// (keep-alive/HTTP2 复用连接上观测到 8~46s 不 EOF)。
type hangingOpenAISSEAfterTerminal struct {
payload []byte
sent bool
release chan struct{}
closeOnce sync.Once
}
func (r *hangingOpenAISSEAfterTerminal) Read(data []byte) (int, error) {
if !r.sent {
r.sent = true
return copy(data, r.payload), nil
}
<-r.release
return 0, io.EOF
}
func (r *hangingOpenAISSEAfterTerminal) Close() error {
r.closeOnce.Do(func() { close(r.release) })
return nil
}
func TestOpenAIResponseFlush_TerminalEventEndsStreamWithoutEOF(t *testing.T) {
body := "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":7,\"output_tokens\":5}}}\n\n"
reader := &hangingOpenAISSEAfterTerminal{payload: []byte(body), release: make(chan struct{})}
recorder := newOpenAIResponseFlushRecorder()
resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, config.GatewayConfig{StreamKeepaliveInterval: 1, StreamDataIntervalTimeout: 30})
t.Cleanup(func() { _ = reader.Close() })
select {
case err := <-errCh:
require.NoError(t, err)
result := <-resultCh
require.NotNil(t, result)
require.Equal(t, 7, result.usage.InputTokens)
require.Equal(t, 5, result.usage.OutputTokens)
case <-time.After(3 * time.Second):
t.Fatal("stream did not end after terminal event; still waiting for upstream EOF")
}
gotBody, _ := recorder.snapshot()
require.Equal(t, body, gotBody)
}
@@ -803,6 +803,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
if streamEarlyErr != nil {
return resultWithUsage(), streamEarlyErr
}
// Terminal 事件完整写出后直接结束,不等上游 EOF(见下方异步循环同款说明)。
// Codex bare error 序列(error 后可能跟 response.failed 或翻盘的 completed)
// 必须继续读取,不适用提前结束。
if sawTerminalEvent && !eventInProgress && (!codexFailureTerminal || !sawBareError) {
return finalizeStream()
}
}
if result, err, done := handleScanErr(documentScanner.Err()); done {
return result, err
@@ -882,6 +888,15 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
if streamEarlyErr != nil {
return resultWithUsage(), streamEarlyErr
}
// Terminal 事件(response.completed 等)已完整写出后不再等上游 EOF:
// 上游在 keep-alive/HTTP2 复用连接上可能拖延关闭连接,空等期间只能
// 靠 keepalive 维持,白白拉长尾延迟。usage 已在 terminal 事件中解析。
// Codex bare error 序列(error 后可能跟 response.failed 或翻盘的 completed)
// 必须继续读取,不适用提前结束。
if sawTerminalEvent && !eventInProgress && (!codexFailureTerminal || !sawBareError) {
_ = resp.Body.Close()
return finalizeStream()
}
case <-intervalCh:
if failureDelivered {