mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
fix(antigravity): Gemini 原生流对 go-genai / python-genai 客户端不发 SSE 注释心跳
Antigravity 账号的 Gemini 原生流式路径在上游空闲超过 gateway.stream_keepalive_interval (默认 10s)时会向下游写 ":\n\n" 作为心跳。SSE 规范允许注释行,但 Google 官方 SDK 里 go-genai 与 python-genai 都不会忽略它: - go-genai(Antigravity CLI 在用)api_client.go iterateResponseStream 对任何非 data: 前缀的 事件直接返回 "iterateResponseStream: invalid stream chunk: :",整个流中断; - python-genai _api_client.py 把非 data: 行当作错误 JSON 拼接后 json.loads,同样抛错。 Google 官方 API 从不下发注释行,这两个 SDK 从未处理过这种输入。js-genai 用规范的 event-stream 解析器,不受影响。 现在按下游 User-Agent / X-Goog-Api-Client 识别这两类 SDK(形如 google-genai-sdk/1.71.0 gl-go/go1.28 …、google-genai-sdk/x gl-python/3.12),命中时该路径 不再发注释心跳;其他客户端(curl、js-genai、Gemini CLI)行为不变。 生产复现:Antigravity CLI 1.2.5 经网关(sub2api 0.2.5)跑长思考请求,上游首个 chunk 超过 10s 时客户端报 "agent executor error: … iterateResponseStream: invalid stream chunk: :"。
This commit is contained in:
@@ -197,6 +197,11 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
|
||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 {
|
||||
keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second
|
||||
}
|
||||
// go-genai / python-genai 不会忽略 SSE 注释行,收到 ":\n\n" 会直接把整个流判成
|
||||
// invalid stream chunk 而中断(Antigravity CLI 在用 go-genai)。对这类客户端宁可不发心跳。
|
||||
if keepaliveInterval > 0 && downstreamRejectsSSEComments(c) {
|
||||
keepaliveInterval = 0
|
||||
}
|
||||
var keepaliveTicker *time.Ticker
|
||||
if keepaliveInterval > 0 {
|
||||
keepaliveTicker = time.NewTicker(keepaliveInterval)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// geminiClientRejectsSSEComments 判断下游客户端是否是「无法容忍 SSE 注释行」的 Google GenAI SDK。
|
||||
//
|
||||
// Gemini 原生流里我们用 ":\n\n" 作为空闲心跳(防止代理/Cloudflare Tunnel 断开),但官方 SDK 里
|
||||
// 有两个实现不按 SSE 规范忽略注释行:
|
||||
// - go-genai(Antigravity CLI 在用):api_client.go iterateResponseStream 对任何非 "data:" 前缀的
|
||||
// 事件直接报 "invalid stream chunk: :",整个流中断;
|
||||
// - python-genai:_api_client.py 把非 "data:" 行当作错误 JSON 拼接后 json.loads,同样抛错。
|
||||
//
|
||||
// js-genai 用的是规范的 event-stream 解析器,能正确忽略注释。Google 官方 API 从不下发注释行,
|
||||
// 所以这两个 SDK 从未处理过这种输入。识别依据是 SDK 固定携带的 User-Agent / X-Goog-Api-Client,
|
||||
// 形如 "google-genai-sdk/1.71.0 gl-go/go1.28 ..." 或 "google-genai-sdk/1.x gl-python/3.12"。
|
||||
func geminiClientRejectsSSEComments(clientHint string) bool {
|
||||
hint := strings.ToLower(strings.TrimSpace(clientHint))
|
||||
if !strings.Contains(hint, "google-genai-sdk/") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(hint, "gl-go/") || strings.Contains(hint, "gl-python/")
|
||||
}
|
||||
|
||||
// downstreamRejectsSSEComments 从请求头识别下游客户端(同时看 User-Agent 与 X-Goog-Api-Client)。
|
||||
func downstreamRejectsSSEComments(c *gin.Context) bool {
|
||||
if c == nil || c.Request == nil {
|
||||
return false
|
||||
}
|
||||
return geminiClientRejectsSSEComments(c.GetHeader("User-Agent")) ||
|
||||
geminiClientRejectsSSEComments(c.GetHeader("X-Goog-Api-Client"))
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGeminiClientRejectsSSEComments(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
hint string
|
||||
want bool
|
||||
}{
|
||||
{"go-genai (Antigravity CLI)", "google-genai-sdk/1.71.0 gl-go/go1.28-20260721-RC03 cl/951519500 +3ebc191975 X:fieldtrack,boringcrypto", true},
|
||||
{"python-genai", "google-genai-sdk/1.20.0 gl-python/3.12.4", true},
|
||||
{"js-genai tolerates comments", "google-genai-sdk/1.9.0 gl-node/22.3.0", false},
|
||||
{"gemini-cli", "GeminiCLI/0.60.0 (darwin; arm64)", false},
|
||||
{"curl", "curl/8.7.1", false},
|
||||
{"empty", "", false},
|
||||
{"case-insensitive", "Google-GenAI-SDK/1.0.0 GL-Go/go1.27", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, geminiClientRejectsSSEComments(tc.hint))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownstreamRejectsSSECommentsReadsBothHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := newAntigravityCompatContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:streamGenerateContent", nil)
|
||||
require.False(t, downstreamRejectsSSEComments(c))
|
||||
|
||||
c.Request.Header.Set("X-Goog-Api-Client", "google-genai-sdk/1.71.0 gl-go/go1.28")
|
||||
require.True(t, downstreamRejectsSSEComments(c))
|
||||
|
||||
c.Request.Header.Del("X-Goog-Api-Client")
|
||||
c.Request.Header.Set("User-Agent", "google-genai-sdk/1.71.0 gl-go/go1.28")
|
||||
require.True(t, downstreamRejectsSSEComments(c))
|
||||
|
||||
require.False(t, downstreamRejectsSSEComments(nil))
|
||||
}
|
||||
|
||||
// runAntigravityGeminiStreamWithIdle 起一条上游流:先发一个 data 事件,然后空闲 idle 时长再关闭,
|
||||
// 返回写给下游的全部字节。用来观察空闲期间网关是否发了 ":\n\n" 心跳。
|
||||
func runAntigravityGeminiStreamWithIdle(t *testing.T, userAgent string, idle time.Duration) string {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newAntigravityCompatService(
|
||||
config.GatewayConfig{MaxLineSize: defaultMaxLineSize, StreamKeepaliveInterval: 1},
|
||||
nil,
|
||||
)
|
||||
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:streamGenerateContent", nil)
|
||||
if userAgent != "" {
|
||||
c.Request.Header.Set("User-Agent", userAgent)
|
||||
}
|
||||
reader, writer := io.Pipe()
|
||||
resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: reader}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
|
||||
done <- err
|
||||
}()
|
||||
_, err := io.WriteString(
|
||||
writer,
|
||||
`data: {"response":{"responseId":"resp_1","candidates":[{"content":{"parts":[{"text":"partial"}]}}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":1}}}`+"\n\n",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(idle)
|
||||
require.NoError(t, writer.Close())
|
||||
require.NoError(t, <-done)
|
||||
require.NoError(t, reader.Close())
|
||||
return recorder.Body.String()
|
||||
}
|
||||
|
||||
func TestAntigravityGeminiStreamKeepsCommentKeepaliveForOrdinaryClients(t *testing.T) {
|
||||
out := runAntigravityGeminiStreamWithIdle(t, "curl/8.7.1", 1200*time.Millisecond)
|
||||
require.Contains(t, out, ":\n\n", "ordinary clients should still get the idle keepalive")
|
||||
require.Contains(t, out, `"text":"partial"`)
|
||||
}
|
||||
|
||||
func TestAntigravityGeminiStreamSkipsCommentKeepaliveForGoGenai(t *testing.T) {
|
||||
out := runAntigravityGeminiStreamWithIdle(t, "google-genai-sdk/1.71.0 gl-go/go1.28-20260721-RC03", 1200*time.Millisecond)
|
||||
require.Contains(t, out, `"text":"partial"`)
|
||||
for _, event := range strings.Split(out, "\n\n") {
|
||||
require.False(t, strings.HasPrefix(event, ":"), "go-genai must never receive an SSE comment event, got %q", event)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user