diff --git a/backend/internal/service/antigravity_gateway_streaming.go b/backend/internal/service/antigravity_gateway_streaming.go index 5922554207..59d4e459b7 100644 --- a/backend/internal/service/antigravity_gateway_streaming.go +++ b/backend/internal/service/antigravity_gateway_streaming.go @@ -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) diff --git a/backend/internal/service/gemini_sse_comment_compat.go b/backend/internal/service/gemini_sse_comment_compat.go new file mode 100644 index 0000000000..3a23f6db72 --- /dev/null +++ b/backend/internal/service/gemini_sse_comment_compat.go @@ -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")) +} diff --git a/backend/internal/service/gemini_sse_comment_compat_test.go b/backend/internal/service/gemini_sse_comment_compat_test.go new file mode 100644 index 0000000000..360f51ad38 --- /dev/null +++ b/backend/internal/service/gemini_sse_comment_compat_test.go @@ -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) + } +}