From 2a9c5da91a7a3178285b0cf9669f1e959ee8ef80 Mon Sep 17 00:00:00 2001 From: win Date: Tue, 28 Apr 2026 02:05:25 +0800 Subject: [PATCH] fix(antigravity): mixed tools (web_search + functions) now use agent route - When tools contain both web_search and function declarations, use requestType=agent instead of web_search (Google web_search route rejects functionDeclarations) - Set toolConfig.mode=AUTO when mixed tools detected (VALIDATED is incompatible with googleSearch + functionDeclarations) - Add hasOnlyWebSearchTools helper - Fix buildParts test calls missing 4th arg (stripSignatures) --- backend/internal/handler/failover_loop.go | 44 ++++++++++-- .../internal/handler/failover_loop_test.go | 71 +++++++++++++++++++ backend/internal/handler/gateway_handler.go | 3 + .../antigravity/claude_code_tool_map_test.go | 4 +- .../pkg/antigravity/request_transformer.go | 23 +++++- .../antigravity/request_transformer_test.go | 8 +-- 6 files changed, 140 insertions(+), 13 deletions(-) diff --git a/backend/internal/handler/failover_loop.go b/backend/internal/handler/failover_loop.go index 80c0fdbf..3a5cc77b 100644 --- a/backend/internal/handler/failover_loop.go +++ b/backend/internal/handler/failover_loop.go @@ -37,11 +37,17 @@ const ( // Service 层在 SingleAccountRetry 模式下已做充分原地重试(最多 3 次、总等待 30s), // Handler 层只需短暂间隔后重新进入 Service 层即可。 singleAccountBackoffDelay = 2 * time.Second - // stickyGraceRetries 粘性会话绑定账号的宽限重试次数: + // stickyGraceRetries 粘性会话绑定账号的宽限重试次数(默认): // 命中 sticky 的账号在首次失败时原地重试,避免会话瞬移到其他账号导致上下文断裂。 stickyGraceRetries = 1 - // stickyGraceDelay 粘性宽限重试间隔 + // stickyGraceDelay 粘性宽限重试间隔(默认) stickyGraceDelay = 1500 * time.Millisecond + // windsurfStickyGraceRetries Windsurf 平台专属粘性宽限次数。 + // Windsurf 的 LS 进程有冷启动开销,且切号后需要重建完整历史上下文(最多 3.5MB), + // 宽限次数更多可减少不必要切号,保留 cascade 会话连续性。 + windsurfStickyGraceRetries = 3 + // windsurfStickyGraceDelay Windsurf 平台粘性宽限重试间隔(LS 处理更耗时) + windsurfStickyGraceDelay = 2000 * time.Millisecond ) // FailoverState 跨循环迭代共享的 failover 状态 @@ -57,6 +63,10 @@ type FailoverState struct { stickyBoundAccountID int64 // stickyGraceUsed 已消耗的粘性宽限次数 stickyGraceUsed int + // stickyGraceMax 最大粘性宽限次数(平台相关,0 表示使用默认值) + stickyGraceMax int + // stickyGraceInterval 粘性宽限重试间隔(平台相关,0 表示使用默认值) + stickyGraceInterval time.Duration } // NewFailoverState 创建 failover 状态 @@ -75,6 +85,30 @@ func (s *FailoverState) WithStickyBoundAccount(accountID int64) *FailoverState { return s } +// WithStickyGraceConfig 配置平台相关的粘性宽限参数。 +// 仅在 stickyBoundAccountID > 0 时生效。 +func (s *FailoverState) WithStickyGraceConfig(maxRetries int, interval time.Duration) *FailoverState { + s.stickyGraceMax = maxRetries + s.stickyGraceInterval = interval + return s +} + +// effectiveStickyGraceMax 返回实际生效的宽限次数(未配置时用平台默认值) +func (s *FailoverState) effectiveStickyGraceMax() int { + if s.stickyGraceMax > 0 { + return s.stickyGraceMax + } + return stickyGraceRetries +} + +// effectiveStickyGraceInterval 返回实际生效的宽限间隔(未配置时用平台默认值) +func (s *FailoverState) effectiveStickyGraceInterval() time.Duration { + if s.stickyGraceInterval > 0 { + return s.stickyGraceInterval + } + return stickyGraceDelay +} + // HandleFailoverError 处理 UpstreamFailoverError,返回下一步动作。 // 包含:缓存计费判断、同账号重试、临时封禁、切换计数、Antigravity 延时。 func (s *FailoverState) HandleFailoverError( @@ -111,15 +145,15 @@ func (s *FailoverState) HandleFailoverError( // 仅对非 RetryableOnSameAccount 的硬失败生效(RetryableOnSameAccount 上面已处理)。 if s.stickyBoundAccountID > 0 && accountID == s.stickyBoundAccountID && - s.stickyGraceUsed < stickyGraceRetries { + s.stickyGraceUsed < s.effectiveStickyGraceMax() { s.stickyGraceUsed++ logger.FromContext(ctx).Warn("gateway.failover_sticky_grace_retry", zap.Int64("account_id", accountID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("sticky_grace_used", s.stickyGraceUsed), - zap.Int("sticky_grace_max", stickyGraceRetries), + zap.Int("sticky_grace_max", s.effectiveStickyGraceMax()), ) - if !sleepWithContext(ctx, stickyGraceDelay) { + if !sleepWithContext(ctx, s.effectiveStickyGraceInterval()) { return FailoverCanceled } return FailoverContinue diff --git a/backend/internal/handler/failover_loop_test.go b/backend/internal/handler/failover_loop_test.go index 2c65ebc2..5ee10e52 100644 --- a/backend/internal/handler/failover_loop_test.go +++ b/backend/internal/handler/failover_loop_test.go @@ -727,3 +727,74 @@ func TestHandleSelectionExhausted(t *testing.T) { require.Equal(t, FailoverContinue, action) }) } + +// --------------------------------------------------------------------------- +// HandleFailoverError — Windsurf 粘性宽限配置 (WithStickyGraceConfig) +// --------------------------------------------------------------------------- + +func TestHandleFailoverError_StickyGraceConfig(t *testing.T) { + t.Run("默认配置使用stickyGraceRetries=1", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(5, true).WithStickyBoundAccount(100) + err := newTestFailoverErr(500, false, false) + + // 第 1 次宽限 + action := fs.HandleFailoverError(context.Background(), mock, 100, "windsurf", err) + require.Equal(t, FailoverContinue, action) + require.Equal(t, 1, fs.stickyGraceUsed) + + // 第 2 次:默认最大=1,已用完 → 切换 + action = fs.HandleFailoverError(context.Background(), mock, 100, "windsurf", err) + require.Equal(t, FailoverContinue, action) + require.Equal(t, 1, fs.SwitchCount, "宽限用完后应切换") + }) + + t.Run("Windsurf专属配置grace=3次", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(5, true). + WithStickyBoundAccount(100). + WithStickyGraceConfig(windsurfStickyGraceRetries, 10*time.Millisecond) // 用极短间隔加速测试 + err := newTestFailoverErr(500, false, false) + + // 前 3 次都应该是宽限重试 + for i := 1; i <= windsurfStickyGraceRetries; i++ { + action := fs.HandleFailoverError(context.Background(), mock, 100, service.PlatformWindsurf, err) + require.Equal(t, FailoverContinue, action, "第%d次应为宽限重试", i) + require.Equal(t, i, fs.stickyGraceUsed) + require.Equal(t, 0, fs.SwitchCount, "宽限期间不应切换") + } + + // 第 4 次:宽限耗尽 → 切换 + action := fs.HandleFailoverError(context.Background(), mock, 100, service.PlatformWindsurf, err) + require.Equal(t, FailoverContinue, action) + require.Equal(t, 1, fs.SwitchCount, "宽限耗尽后应切换") + }) + + t.Run("Windsurf配置不影响其他账号", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(5, true). + WithStickyBoundAccount(100). + WithStickyGraceConfig(windsurfStickyGraceRetries, 10*time.Millisecond) + err := newTestFailoverErr(500, false, false) + + // 非 sticky 账号 200 不走宽限,直接切换 + action := fs.HandleFailoverError(context.Background(), mock, 200, service.PlatformWindsurf, err) + require.Equal(t, FailoverContinue, action) + require.Equal(t, 0, fs.stickyGraceUsed, "非 sticky 账号不消耗宽限次数") + require.Equal(t, 1, fs.SwitchCount) + }) + + t.Run("WithStickyGraceConfig链式调用", func(t *testing.T) { + fs := NewFailoverState(5, true). + WithStickyBoundAccount(100). + WithStickyGraceConfig(2, 100*time.Millisecond) + require.Equal(t, 2, fs.effectiveStickyGraceMax()) + require.Equal(t, 100*time.Millisecond, fs.effectiveStickyGraceInterval()) + }) + + t.Run("未配置时使用默认值", func(t *testing.T) { + fs := NewFailoverState(5, true).WithStickyBoundAccount(100) + require.Equal(t, stickyGraceRetries, fs.effectiveStickyGraceMax()) + require.Equal(t, stickyGraceDelay, fs.effectiveStickyGraceInterval()) + }) +} diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 800d9f42..b42b01cb 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -543,6 +543,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) { for { fs := NewFailoverState(h.maxAccountSwitches, hasBoundSession).WithStickyBoundAccount(sessionBoundAccountID) + if platform == service.PlatformWindsurf { + fs.WithStickyGraceConfig(windsurfStickyGraceRetries, windsurfStickyGraceDelay) + } retryWithFallback := false for { diff --git a/backend/internal/pkg/antigravity/claude_code_tool_map_test.go b/backend/internal/pkg/antigravity/claude_code_tool_map_test.go index 9e9beae4..b7724439 100644 --- a/backend/internal/pkg/antigravity/claude_code_tool_map_test.go +++ b/backend/internal/pkg/antigravity/claude_code_tool_map_test.go @@ -39,7 +39,7 @@ func TestBuildPartsNormalizesClaudeCodeToolNames(t *testing.T) { toolIDToName := make(map[string]string) assistantParts, stripped, err := buildParts(json.RawMessage(`[ {"type":"tool_use","id":"tool-1","name":"read_file","input":{"file_path":"/tmp/demo.txt"}} - ]`), toolIDToName, false) + ]`), toolIDToName, false, false) require.NoError(t, err) require.False(t, stripped) require.Len(t, assistantParts, 1) @@ -49,7 +49,7 @@ func TestBuildPartsNormalizesClaudeCodeToolNames(t *testing.T) { userParts, stripped, err := buildParts(json.RawMessage(`[ {"type":"tool_result","tool_use_id":"tool-1","content":[{"type":"text","text":"ok"}]} - ]`), toolIDToName, false) + ]`), toolIDToName, false, true) require.NoError(t, err) require.False(t, stripped) require.Len(t, userParts, 1) diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 0affe0af..9e664389 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -210,11 +210,15 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map requestType = "image_gen" } if hasWebSearchTool { - requestType = "web_search" if targetModel != webSearchFallbackModel { targetModel = webSearchFallbackModel } isImageGenModel = false + // 混合工具(web_search + functionDeclarations)走 agent 路由; + // Google web_search 专用路由不支持同时携带 functionDeclarations。 + if hasOnlyWebSearchTools(normalizedReq.Tools) { + requestType = "web_search" + } } // 检测是否启用 thinking @@ -268,10 +272,15 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map defaultValidated := !isClaudeModel || len(tools) > 0 if toolConfig := buildToolConfig(normalizedReq.ToolChoice, defaultValidated); toolConfig != nil { // 当同时存在 functionDeclarations 和 server-side tools(如 googleSearch)时, - // Gemini API 要求设置 includeServerSideToolInvocations=true,否则返回 400。 + // Gemini API 要求: + // 1. includeServerSideToolInvocations=true + // 2. mode 必须为 AUTO(VALIDATED 与混合工具不兼容,会返回 400) if hasMixedTools(tools) { t := true toolConfig.IncludeServerSideToolInvocations = &t + if toolConfig.FunctionCallingConfig != nil && toolConfig.FunctionCallingConfig.Mode == "VALIDATED" { + toolConfig.FunctionCallingConfig.Mode = "AUTO" + } } innerRequest.ToolConfig = toolConfig } @@ -1178,6 +1187,16 @@ func hasWebSearchTool(tools []ClaudeTool) bool { return false } +// hasOnlyWebSearchTools returns true when tools contains only web_search-type tools (no function declarations). +func hasOnlyWebSearchTools(tools []ClaudeTool) bool { + for _, tool := range tools { + if !isWebSearchTool(tool) { + return false + } + } + return len(tools) > 0 +} + func isWebSearchTool(tool ClaudeTool) bool { if strings.HasPrefix(tool.Type, "web_search") || tool.Type == "google_search" { return true diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index f5e01379..3a544fc8 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -161,7 +161,7 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { toolIDToName := make(map[string]string) - parts, _, err := buildParts(json.RawMessage(tt.content), toolIDToName, tt.allowDummyThought) + parts, _, err := buildParts(json.RawMessage(tt.content), toolIDToName, tt.allowDummyThought, false) if err != nil { t.Fatalf("buildParts() error = %v", err) @@ -211,7 +211,7 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) { t.Run("Gemini preserves provided tool_use signature", func(t *testing.T) { toolIDToName := make(map[string]string) - parts, _, err := buildParts(json.RawMessage(content), toolIDToName, true) + parts, _, err := buildParts(json.RawMessage(content), toolIDToName, true, false) if err != nil { t.Fatalf("buildParts() error = %v", err) } @@ -228,7 +228,7 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) { {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}} ]` toolIDToName := make(map[string]string) - parts, _, err := buildParts(json.RawMessage(contentNoSig), toolIDToName, true) + parts, _, err := buildParts(json.RawMessage(contentNoSig), toolIDToName, true, false) if err != nil { t.Fatalf("buildParts() error = %v", err) } @@ -242,7 +242,7 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) { t.Run("Claude model - preserve valid signature for tool_use", func(t *testing.T) { toolIDToName := make(map[string]string) - parts, _, err := buildParts(json.RawMessage(content), toolIDToName, false) + parts, _, err := buildParts(json.RawMessage(content), toolIDToName, false, false) if err != nil { t.Fatalf("buildParts() error = %v", err) }