mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
The passthrough streaming loop buffers leading events in `pendingLines` until
the first visible output, so a client receives **zero bytes** — not even the
HTTP response headers, since gin's ResponseWriter only commits them on the
first write — for as long as the upstream takes to produce its first visible
event.
The Forward path already recognises this exact hazard and solves it with a
downstream keepalive:
// Track downstream writes separately from upstream reads: pre-output
// failover can buffer response.created / response.in_progress, so
// keepalive must be based on downstream idle time.
The passthrough path has the same buffering but never starts a keepalive.
Reasoning models routinely think for several minutes before emitting a visible
event, so intermediate proxies close the connection on idle timeout.
Production evidence from one deployment (12h window, gpt-5.6 family):
* 44 `/v1/responses` requests produced their first visible output only after
600–900 s, each carrying 30k–50k output tokens — the upstream had finished
the work.
* Every one of them was killed by an intermediate nginx `proxy_read_timeout`
(600 s) and returned 504. The user received nothing.
* Cutting early does not even save resources: the code deliberately keeps
draining the upstream for usage after the client disconnects.
Fix: start the existing keepalive helper for the passthrough stream as well.
* `StartOpenAICompactSSEKeepalive` keeps its compact-marker gate; the gate-free
body is extracted as `startOpenAISSEKeepalive` for callers that already know
they are in an SSE context.
* The passthrough loop starts it when `gateway.stream_keepalive_interval > 0`
and stops it immediately before its first real write, so the ResponseWriter
is never shared.
Pre-output failover is unaffected: keepalive bytes are already excluded by
`OpenAICompactKeepaliveAdjustedWrittenSize`, which `openAIStreamClientOutputStarted`
on this very path already consults (#3887). No pendingLines and no
account-specific headers are ever emitted by a keepalive.
Behaviour is unchanged when `gateway.stream_keepalive_interval` is 0.
108 lines
4.4 KiB
Go
108 lines
4.4 KiB
Go
package service
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// 这一组守住的是「首个可见输出之前下游不再静默」这条性质。
|
|
//
|
|
// 透传路径的 pendingLines 会把 response.created / response.in_progress 全部扣住,
|
|
// 于是首个可见输出之前下游一个字节都收不到,连 HTTP 响应头都不会提交。推理模型
|
|
// 思考数百秒时,中间层代理会按空闲超时把连接判死。Forward 路径早就用心跳解决了
|
|
// 这个问题(见 openai_gateway_response_handling.go 中 lastDownstreamWriteAt 的注释),
|
|
// 透传路径漏了。
|
|
|
|
func newPassthroughKeepaliveTestContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
// 刻意【不】调用 MarkOpenAICompactClientStream:普通 /v1/responses 透传不带
|
|
// compact 标记,这正是它此前拿不到心跳的原因。
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
|
return c, rec
|
|
}
|
|
|
|
// startOpenAISSEKeepalive 必须在【没有】compact 标记时也能启动 ——
|
|
// 否则普通透传请求依旧静默。
|
|
func TestStartOpenAISSEKeepalive_WorksWithoutCompactMarker(t *testing.T) {
|
|
c, rec := newPassthroughKeepaliveTestContext(t)
|
|
|
|
// 对照:带 compact 标记检查的入口在这里应当直接 no-op。
|
|
stop := StartOpenAICompactSSEKeepalive(c, keepaliveTestInterval)
|
|
waitForKeepaliveBeats()
|
|
stop()
|
|
require.Zero(t, rec.Body.Len(), "无 compact 标记时 StartOpenAICompactSSEKeepalive 应当 no-op")
|
|
|
|
// 内部入口不检查标记,应当真的开始打拍。
|
|
c, rec = newPassthroughKeepaliveTestContext(t)
|
|
stop = startOpenAISSEKeepalive(c, keepaliveTestInterval)
|
|
defer stop()
|
|
waitForKeepaliveBeats()
|
|
|
|
require.True(t, StopOpenAICompactSSEKeepaliveCommitted(c), "心跳应当提交响应头")
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
|
|
require.Equal(t, "no", rec.Header().Get("X-Accel-Buffering"))
|
|
require.Contains(t, rec.Body.String(), ": keepalive\n\n")
|
|
}
|
|
|
|
// 🔴 最要紧的一条:心跳字节【不得】把请求判成「已向客户端写出语义响应」,
|
|
// 否则上游 429/5xx 时不再换号 —— 这正是 #3887 加固的那条不变量,
|
|
// 透传路径的 pre-output failover 完全依赖它。
|
|
func TestPassthroughKeepaliveDoesNotBlockPreOutputFailover(t *testing.T) {
|
|
c, rec := newPassthroughKeepaliveTestContext(t)
|
|
stop := startOpenAISSEKeepalive(c, keepaliveTestInterval)
|
|
defer stop()
|
|
waitForKeepaliveBeats()
|
|
require.True(t, StopOpenAICompactSSEKeepaliveCommitted(c))
|
|
require.NotZero(t, rec.Body.Len(), "前提:心跳确实写出了字节")
|
|
|
|
// 只有心跳字节时,仍应判定为「尚未向客户端输出」。
|
|
require.False(t, openAIStreamClientOutputStarted(c, false),
|
|
"心跳字节不构成语义输出,pre-output failover 必须仍然可用")
|
|
|
|
// 写出一条真实事件之后,判定才翻转。
|
|
_, err := c.Writer.Write([]byte("data: {\"type\":\"response.output_text.delta\"}\n\n"))
|
|
require.NoError(t, err)
|
|
require.True(t, openAIStreamClientOutputStarted(c, false),
|
|
"真实语义输出之后应当判定为已输出")
|
|
}
|
|
|
|
// 停拍之后不得再有心跳字节写出 —— 主循环接管 ResponseWriter 的前提。
|
|
func TestPassthroughKeepaliveStopsBeforeHandingOverWriter(t *testing.T) {
|
|
c, rec := newPassthroughKeepaliveTestContext(t)
|
|
stop := startOpenAISSEKeepalive(c, keepaliveTestInterval)
|
|
waitForKeepaliveBeats()
|
|
stop()
|
|
|
|
before := rec.Body.String()
|
|
waitForKeepaliveBeats()
|
|
require.Equal(t, before, rec.Body.String(), "停拍后不应再有字节写出")
|
|
|
|
// 停拍后主循环写出的内容不应被心跳穿插。
|
|
_, err := c.Writer.Write([]byte("data: real\n\n"))
|
|
require.NoError(t, err)
|
|
waitForKeepaliveBeats()
|
|
require.True(t, strings.HasSuffix(rec.Body.String(), "data: real\n\n"),
|
|
"停拍后写入应当是响应体的最后一段")
|
|
}
|
|
|
|
// interval<=0(配置禁用)时行为与改动前完全一致:一个字节都不写。
|
|
func TestPassthroughKeepaliveDisabledKeepsWriterUntouched(t *testing.T) {
|
|
c, rec := newPassthroughKeepaliveTestContext(t)
|
|
stop := startOpenAISSEKeepalive(c, 0)
|
|
waitForKeepaliveBeats()
|
|
stop()
|
|
require.Zero(t, rec.Body.Len())
|
|
require.False(t, StopOpenAICompactSSEKeepaliveCommitted(c))
|
|
_ = time.Now
|
|
}
|