fix(antigravity): mixed tools (web_search + functions) now use agent route
Some checks failed
CI / test (push) Failing after 3s
CI / frontend (push) Failing after 3s
CI / golangci-lint (push) Failing after 6s
Security Scan / backend-security (push) Failing after 3s
Security Scan / frontend-security (push) Failing after 3s
CI / windsurf-platform (macos-latest) (push) Has been cancelled
CI / windsurf-platform (windows-latest) (push) Has been cancelled

- 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)
This commit is contained in:
win 2026-04-28 02:05:25 +08:00
parent 9da079a5ee
commit 2a9c5da91a
6 changed files with 140 additions and 13 deletions

View File

@ -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

View File

@ -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())
})
}

View File

@ -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 {

View File

@ -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)

View File

@ -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 必须为 AUTOVALIDATED 与混合工具不兼容,会返回 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

View File

@ -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)
}