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:
HuntercodeT
2026-09-17 19:41:55 +08:00
parent efe9aab1e4
commit f79b8bf96c
3 changed files with 135 additions and 0 deletions
@@ -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)
}
}