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
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:
parent
9da079a5ee
commit
2a9c5da91a
@ -37,11 +37,17 @@ const (
|
|||||||
// Service 层在 SingleAccountRetry 模式下已做充分原地重试(最多 3 次、总等待 30s),
|
// Service 层在 SingleAccountRetry 模式下已做充分原地重试(最多 3 次、总等待 30s),
|
||||||
// Handler 层只需短暂间隔后重新进入 Service 层即可。
|
// Handler 层只需短暂间隔后重新进入 Service 层即可。
|
||||||
singleAccountBackoffDelay = 2 * time.Second
|
singleAccountBackoffDelay = 2 * time.Second
|
||||||
// stickyGraceRetries 粘性会话绑定账号的宽限重试次数:
|
// stickyGraceRetries 粘性会话绑定账号的宽限重试次数(默认):
|
||||||
// 命中 sticky 的账号在首次失败时原地重试,避免会话瞬移到其他账号导致上下文断裂。
|
// 命中 sticky 的账号在首次失败时原地重试,避免会话瞬移到其他账号导致上下文断裂。
|
||||||
stickyGraceRetries = 1
|
stickyGraceRetries = 1
|
||||||
// stickyGraceDelay 粘性宽限重试间隔
|
// stickyGraceDelay 粘性宽限重试间隔(默认)
|
||||||
stickyGraceDelay = 1500 * time.Millisecond
|
stickyGraceDelay = 1500 * time.Millisecond
|
||||||
|
// windsurfStickyGraceRetries Windsurf 平台专属粘性宽限次数。
|
||||||
|
// Windsurf 的 LS 进程有冷启动开销,且切号后需要重建完整历史上下文(最多 3.5MB),
|
||||||
|
// 宽限次数更多可减少不必要切号,保留 cascade 会话连续性。
|
||||||
|
windsurfStickyGraceRetries = 3
|
||||||
|
// windsurfStickyGraceDelay Windsurf 平台粘性宽限重试间隔(LS 处理更耗时)
|
||||||
|
windsurfStickyGraceDelay = 2000 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
// FailoverState 跨循环迭代共享的 failover 状态
|
// FailoverState 跨循环迭代共享的 failover 状态
|
||||||
@ -57,6 +63,10 @@ type FailoverState struct {
|
|||||||
stickyBoundAccountID int64
|
stickyBoundAccountID int64
|
||||||
// stickyGraceUsed 已消耗的粘性宽限次数
|
// stickyGraceUsed 已消耗的粘性宽限次数
|
||||||
stickyGraceUsed int
|
stickyGraceUsed int
|
||||||
|
// stickyGraceMax 最大粘性宽限次数(平台相关,0 表示使用默认值)
|
||||||
|
stickyGraceMax int
|
||||||
|
// stickyGraceInterval 粘性宽限重试间隔(平台相关,0 表示使用默认值)
|
||||||
|
stickyGraceInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFailoverState 创建 failover 状态
|
// NewFailoverState 创建 failover 状态
|
||||||
@ -75,6 +85,30 @@ func (s *FailoverState) WithStickyBoundAccount(accountID int64) *FailoverState {
|
|||||||
return s
|
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,返回下一步动作。
|
// HandleFailoverError 处理 UpstreamFailoverError,返回下一步动作。
|
||||||
// 包含:缓存计费判断、同账号重试、临时封禁、切换计数、Antigravity 延时。
|
// 包含:缓存计费判断、同账号重试、临时封禁、切换计数、Antigravity 延时。
|
||||||
func (s *FailoverState) HandleFailoverError(
|
func (s *FailoverState) HandleFailoverError(
|
||||||
@ -111,15 +145,15 @@ func (s *FailoverState) HandleFailoverError(
|
|||||||
// 仅对非 RetryableOnSameAccount 的硬失败生效(RetryableOnSameAccount 上面已处理)。
|
// 仅对非 RetryableOnSameAccount 的硬失败生效(RetryableOnSameAccount 上面已处理)。
|
||||||
if s.stickyBoundAccountID > 0 &&
|
if s.stickyBoundAccountID > 0 &&
|
||||||
accountID == s.stickyBoundAccountID &&
|
accountID == s.stickyBoundAccountID &&
|
||||||
s.stickyGraceUsed < stickyGraceRetries {
|
s.stickyGraceUsed < s.effectiveStickyGraceMax() {
|
||||||
s.stickyGraceUsed++
|
s.stickyGraceUsed++
|
||||||
logger.FromContext(ctx).Warn("gateway.failover_sticky_grace_retry",
|
logger.FromContext(ctx).Warn("gateway.failover_sticky_grace_retry",
|
||||||
zap.Int64("account_id", accountID),
|
zap.Int64("account_id", accountID),
|
||||||
zap.Int("upstream_status", failoverErr.StatusCode),
|
zap.Int("upstream_status", failoverErr.StatusCode),
|
||||||
zap.Int("sticky_grace_used", s.stickyGraceUsed),
|
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 FailoverCanceled
|
||||||
}
|
}
|
||||||
return FailoverContinue
|
return FailoverContinue
|
||||||
|
|||||||
@ -727,3 +727,74 @@ func TestHandleSelectionExhausted(t *testing.T) {
|
|||||||
require.Equal(t, FailoverContinue, action)
|
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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -543,6 +543,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
fs := NewFailoverState(h.maxAccountSwitches, hasBoundSession).WithStickyBoundAccount(sessionBoundAccountID)
|
fs := NewFailoverState(h.maxAccountSwitches, hasBoundSession).WithStickyBoundAccount(sessionBoundAccountID)
|
||||||
|
if platform == service.PlatformWindsurf {
|
||||||
|
fs.WithStickyGraceConfig(windsurfStickyGraceRetries, windsurfStickyGraceDelay)
|
||||||
|
}
|
||||||
retryWithFallback := false
|
retryWithFallback := false
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|||||||
@ -39,7 +39,7 @@ func TestBuildPartsNormalizesClaudeCodeToolNames(t *testing.T) {
|
|||||||
toolIDToName := make(map[string]string)
|
toolIDToName := make(map[string]string)
|
||||||
assistantParts, stripped, err := buildParts(json.RawMessage(`[
|
assistantParts, stripped, err := buildParts(json.RawMessage(`[
|
||||||
{"type":"tool_use","id":"tool-1","name":"read_file","input":{"file_path":"/tmp/demo.txt"}}
|
{"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.NoError(t, err)
|
||||||
require.False(t, stripped)
|
require.False(t, stripped)
|
||||||
require.Len(t, assistantParts, 1)
|
require.Len(t, assistantParts, 1)
|
||||||
@ -49,7 +49,7 @@ func TestBuildPartsNormalizesClaudeCodeToolNames(t *testing.T) {
|
|||||||
|
|
||||||
userParts, stripped, err := buildParts(json.RawMessage(`[
|
userParts, stripped, err := buildParts(json.RawMessage(`[
|
||||||
{"type":"tool_result","tool_use_id":"tool-1","content":[{"type":"text","text":"ok"}]}
|
{"type":"tool_result","tool_use_id":"tool-1","content":[{"type":"text","text":"ok"}]}
|
||||||
]`), toolIDToName, false)
|
]`), toolIDToName, false, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, stripped)
|
require.False(t, stripped)
|
||||||
require.Len(t, userParts, 1)
|
require.Len(t, userParts, 1)
|
||||||
|
|||||||
@ -210,11 +210,15 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
|||||||
requestType = "image_gen"
|
requestType = "image_gen"
|
||||||
}
|
}
|
||||||
if hasWebSearchTool {
|
if hasWebSearchTool {
|
||||||
requestType = "web_search"
|
|
||||||
if targetModel != webSearchFallbackModel {
|
if targetModel != webSearchFallbackModel {
|
||||||
targetModel = webSearchFallbackModel
|
targetModel = webSearchFallbackModel
|
||||||
}
|
}
|
||||||
isImageGenModel = false
|
isImageGenModel = false
|
||||||
|
// 混合工具(web_search + functionDeclarations)走 agent 路由;
|
||||||
|
// Google web_search 专用路由不支持同时携带 functionDeclarations。
|
||||||
|
if hasOnlyWebSearchTools(normalizedReq.Tools) {
|
||||||
|
requestType = "web_search"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检测是否启用 thinking
|
// 检测是否启用 thinking
|
||||||
@ -268,10 +272,15 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
|||||||
defaultValidated := !isClaudeModel || len(tools) > 0
|
defaultValidated := !isClaudeModel || len(tools) > 0
|
||||||
if toolConfig := buildToolConfig(normalizedReq.ToolChoice, defaultValidated); toolConfig != nil {
|
if toolConfig := buildToolConfig(normalizedReq.ToolChoice, defaultValidated); toolConfig != nil {
|
||||||
// 当同时存在 functionDeclarations 和 server-side tools(如 googleSearch)时,
|
// 当同时存在 functionDeclarations 和 server-side tools(如 googleSearch)时,
|
||||||
// Gemini API 要求设置 includeServerSideToolInvocations=true,否则返回 400。
|
// Gemini API 要求:
|
||||||
|
// 1. includeServerSideToolInvocations=true
|
||||||
|
// 2. mode 必须为 AUTO(VALIDATED 与混合工具不兼容,会返回 400)
|
||||||
if hasMixedTools(tools) {
|
if hasMixedTools(tools) {
|
||||||
t := true
|
t := true
|
||||||
toolConfig.IncludeServerSideToolInvocations = &t
|
toolConfig.IncludeServerSideToolInvocations = &t
|
||||||
|
if toolConfig.FunctionCallingConfig != nil && toolConfig.FunctionCallingConfig.Mode == "VALIDATED" {
|
||||||
|
toolConfig.FunctionCallingConfig.Mode = "AUTO"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
innerRequest.ToolConfig = toolConfig
|
innerRequest.ToolConfig = toolConfig
|
||||||
}
|
}
|
||||||
@ -1178,6 +1187,16 @@ func hasWebSearchTool(tools []ClaudeTool) bool {
|
|||||||
return false
|
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 {
|
func isWebSearchTool(tool ClaudeTool) bool {
|
||||||
if strings.HasPrefix(tool.Type, "web_search") || tool.Type == "google_search" {
|
if strings.HasPrefix(tool.Type, "web_search") || tool.Type == "google_search" {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@ -161,7 +161,7 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
toolIDToName := make(map[string]string)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("buildParts() error = %v", err)
|
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) {
|
t.Run("Gemini preserves provided tool_use signature", func(t *testing.T) {
|
||||||
toolIDToName := make(map[string]string)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("buildParts() error = %v", err)
|
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"}}
|
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}}
|
||||||
]`
|
]`
|
||||||
toolIDToName := make(map[string]string)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("buildParts() error = %v", err)
|
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) {
|
t.Run("Claude model - preserve valid signature for tool_use", func(t *testing.T) {
|
||||||
toolIDToName := make(map[string]string)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("buildParts() error = %v", err)
|
t.Fatalf("buildParts() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user