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),
|
||||
// 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
|
||||
|
||||
@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user