From 6c2d2ed0439e08b182d480ad4d185dfeecaebbda Mon Sep 17 00:00:00 2001 From: tunsuy <9447297+tunsuy@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:23:53 +0800 Subject: [PATCH] fix(antigravity): drop mixed web_search when client tools present Antigravity v1internal rejects googleSearch/codeExecution mixed with functionDeclarations even with includeServerSideToolInvocations (#6464). Codex enables web_search by default alongside shell tools, which made Sub2API return opaque upstream_error. Prefer client function tools so agent sessions keep working; keep web_search-only fallback intact. Co-authored-by: Cursor --- .../pkg/antigravity/request_transformer.go | 57 +++++++----- .../antigravity/request_transformer_test.go | 42 +++++---- .../service/antigravity_gateway_compat.go | 75 +++++++++++---- .../antigravity_gateway_compat_test.go | 92 +++++++++++++++---- .../service/antigravity_gateway_gemini.go | 5 + .../antigravity_gateway_service_test.go | 14 ++- 6 files changed, 204 insertions(+), 81 deletions(-) diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 1750848348..6227ea8f4b 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -88,11 +88,14 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map // 用于存储 tool_use id -> name 映射 toolIDToName := make(map[string]string) - // 检测是否有 web_search 工具 - hasWebSearchTool := hasWebSearchTool(claudeReq.Tools) + // 仅在「只有内置 web_search、没有客户端 function tools」时走 web_search 降级模型。 + // Antigravity v1internal 不支持内置工具与 functionDeclarations 混用(即使设置 + // includeServerSideToolInvocations 仍会 400,见 issue #6464),混用时会丢弃内置搜索, + // 因此不能再强制切到 gemini-2.5-flash,否则 Codex 等带 shell 工具的请求会整单失败。 + useWebSearchRequest := hasWebSearchTool(claudeReq.Tools) && !hasClientFunctionTools(claudeReq.Tools) requestType := "agent" targetModel := mappedModel - if hasWebSearchTool { + if useWebSearchRequest { requestType = "web_search" if targetModel != webSearchFallbackModel { targetModel = webSearchFallbackModel @@ -156,13 +159,6 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map Mode: "VALIDATED", }, } - // 内置工具(googleSearch)与函数调用混用时,上游要求显式开启 - // includeServerSideToolInvocations,否则返回 400(issue #5709)。 - // 与 raw 透传路的 enableMixedGeminiToolInvocations 注入保持同一语义。 - if hasMixedToolInvocations(tools) { - enabled := true - innerRequest.ToolConfig.IncludeServerSideToolInvocations = &enabled - } } if systemInstruction != nil { @@ -697,6 +693,20 @@ func hasWebSearchTool(tools []ClaudeTool) bool { return false } +// hasClientFunctionTools 判断是否存在可转发的客户端 function/custom 工具。 +// 内置 web_search / code_execution 不算客户端工具。 +func hasClientFunctionTools(tools []ClaudeTool) bool { + for _, tool := range tools { + if isWebSearchTool(tool) || isCodeExecutionTool(tool) { + continue + } + if strings.TrimSpace(tool.Name) != "" { + return true + } + } + return false +} + func isWebSearchTool(tool ClaudeTool) bool { if strings.HasPrefix(tool.Type, "web_search") || tool.Type == "google_search" { return true @@ -715,21 +725,6 @@ func isCodeExecutionTool(tool ClaudeTool) bool { return strings.TrimSpace(tool.Type) == "code_execution" } -// hasMixedToolInvocations 判断构建后的工具声明是否同时包含函数声明与内置工具 -// (googleSearch)。仅在两者并存时需要开启 includeServerSideToolInvocations。 -func hasMixedToolInvocations(declarations []GeminiToolDeclaration) bool { - hasFunc, hasBuiltin := false, false - for _, d := range declarations { - if len(d.FunctionDeclarations) > 0 { - hasFunc = true - } - if d.GoogleSearch != nil || d.CodeExecution != nil { - hasBuiltin = true - } - } - return hasFunc && hasBuiltin -} - // buildTools 构建 tools func buildTools(tools []ClaudeTool) []GeminiToolDeclaration { if len(tools) == 0 { @@ -795,6 +790,18 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration { }) } + // Antigravity v1internal 协议不支持内置工具与 functionDeclarations 混用: + // 即便带上 includeServerSideToolInvocations 仍返回 400(issue #6464)。 + // Codex 默认同时带 web_search 与 shell 等客户端工具,优先保留客户端工具, + // 使代理会话可继续,而不是整单 upstream_error。 + if len(funcDecls) > 0 { + if hasWebSearch || hasCodeExecution { + log.Printf("[antigravity] dropping built-in tools (web_search/code_execution) because client function tools are present; Antigravity v1internal rejects the mix") + } + hasWebSearch = false + hasCodeExecution = false + } + var declarations []GeminiToolDeclaration if len(funcDecls) > 0 { declarations = append(declarations, GeminiToolDeclaration{ diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index 05764ef7ff..07ef7b4e61 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -555,18 +555,22 @@ func TestTransformClaudeToGeminiWithOptions_PreservesWebSearchAlongsideFunctions }, } - body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "gemini-2.5-flash", DefaultTransformOptions()) + body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-6", DefaultTransformOptions()) require.NoError(t, err) var req V1InternalRequest require.NoError(t, json.Unmarshal(body, &req)) - require.Len(t, req.Request.Tools, 2) + // Antigravity v1internal cannot mix googleSearch with functionDeclarations + // (#6464). Prefer client tools so Codex-style sessions keep working. + require.Equal(t, "agent", req.RequestType) + require.Equal(t, "claude-sonnet-4-6", req.Model) + require.Len(t, req.Request.Tools, 1) require.Len(t, req.Request.Tools[0].FunctionDeclarations, 1) require.Equal(t, "get_weather", req.Request.Tools[0].FunctionDeclarations[0].Name) - require.NotNil(t, req.Request.Tools[1].GoogleSearch) + require.Nil(t, req.Request.Tools[0].GoogleSearch) } -func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) { +func TestGeminiToolConfig_DropsBuiltinsWhenClientFunctionsPresent(t *testing.T) { functionTool := ClaudeTool{ Name: "get_weather", Description: "Get weather information", @@ -577,7 +581,7 @@ func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) { Name: "web_search", } - transform := func(t *testing.T, tools []ClaudeTool) (V1InternalRequest, string) { + transform := func(t *testing.T, tools []ClaudeTool, mappedModel string) (V1InternalRequest, string) { t.Helper() body, err := TransformClaudeToGeminiWithOptions(&ClaudeRequest{ Model: "claude-3-5-sonnet-latest", @@ -588,7 +592,7 @@ func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) { }, }, Tools: tools, - }, "project-1", "gemini-2.5-flash", DefaultTransformOptions()) + }, "project-1", mappedModel, DefaultTransformOptions()) require.NoError(t, err) var req V1InternalRequest @@ -596,27 +600,33 @@ func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) { return req, string(body) } - t.Run("mixed builtin and function tools enable server-side tool invocations", func(t *testing.T) { - req, raw := transform(t, []ClaudeTool{functionTool, webSearchTool}) + t.Run("mixed builtin and function tools keep only client functions", func(t *testing.T) { + req, raw := transform(t, []ClaudeTool{functionTool, webSearchTool}, "claude-sonnet-4-6") - require.NotNil(t, req.Request.ToolConfig) - require.NotNil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations) - require.True(t, *req.Request.ToolConfig.IncludeServerSideToolInvocations) - require.Contains(t, raw, `"includeServerSideToolInvocations":true`) + require.Equal(t, "agent", req.RequestType) + require.Equal(t, "claude-sonnet-4-6", req.Model) + require.Len(t, req.Request.Tools, 1) + require.Len(t, req.Request.Tools[0].FunctionDeclarations, 1) + require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations) + require.NotContains(t, raw, "googleSearch") + require.NotContains(t, raw, "includeServerSideToolInvocations") }) t.Run("function tools only leave the flag unset", func(t *testing.T) { - req, raw := transform(t, []ClaudeTool{functionTool}) + req, raw := transform(t, []ClaudeTool{functionTool}, "gemini-2.5-flash") require.NotNil(t, req.Request.ToolConfig) require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations) require.NotContains(t, raw, "includeServerSideToolInvocations") }) - t.Run("web search only leaves the flag unset", func(t *testing.T) { - req, raw := transform(t, []ClaudeTool{webSearchTool}) + t.Run("web search only keeps googleSearch and fallback model", func(t *testing.T) { + req, raw := transform(t, []ClaudeTool{webSearchTool}, "claude-sonnet-4-6") - require.NotNil(t, req.Request.ToolConfig) + require.Equal(t, "web_search", req.RequestType) + require.Equal(t, "gemini-2.5-flash", req.Model) + require.Len(t, req.Request.Tools, 1) + require.NotNil(t, req.Request.Tools[0].GoogleSearch) require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations) require.NotContains(t, raw, "includeServerSideToolInvocations") }) diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index 5e406656c7..bf92f8b3f8 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -286,35 +286,72 @@ func (s *AntigravityGatewayService) buildAntigravityCompatGeminiBody( return antigravity.TransformClaudeToGeminiWithOptions(claudeRequest, projectID, mappedModel, options) } +// enableMixedGeminiToolInvocations reconciles Antigravity v1internal tool payloads. +// +// Antigravity's cloudcode-pa v1internal endpoint rejects mixing built-in tools +// (googleSearch / codeExecution) with client functionDeclarations — even when +// includeServerSideToolInvocations is set (issue #6464). Prefer client function +// tools (Codex/agent workflows) and drop the incompatible built-ins. func enableMixedGeminiToolInvocations(body []byte) ([]byte, error) { var request map[string]any if err := json.Unmarshal(body, &request); err != nil { return nil, err } - var hasGoogleSearch, hasFunctionDeclarations bool - if tools, ok := request["tools"].([]any); ok { - for _, rawTool := range tools { - tool, ok := rawTool.(map[string]any) - if !ok { - continue - } - _, hasSearch := tool["googleSearch"] - declarations, hasFunctions := tool["functionDeclarations"].([]any) - hasGoogleSearch = hasGoogleSearch || hasSearch - hasFunctionDeclarations = hasFunctionDeclarations || hasFunctions && len(declarations) > 0 - } - } - if !hasGoogleSearch || !hasFunctionDeclarations { + tools, ok := request["tools"].([]any) + if !ok || len(tools) == 0 { return body, nil } - toolConfig, _ := request["toolConfig"].(map[string]any) - if toolConfig == nil { - toolConfig = make(map[string]any) - request["toolConfig"] = toolConfig + hasFunctionDeclarations := false + for _, rawTool := range tools { + tool, ok := rawTool.(map[string]any) + if !ok { + continue + } + declarations, hasFunctions := tool["functionDeclarations"].([]any) + if hasFunctions && len(declarations) > 0 { + hasFunctionDeclarations = true + break + } + } + if !hasFunctionDeclarations { + return body, nil + } + + filtered := make([]any, 0, len(tools)) + droppedBuiltin := false + for _, rawTool := range tools { + tool, ok := rawTool.(map[string]any) + if !ok { + filtered = append(filtered, rawTool) + continue + } + if _, hasSearch := tool["googleSearch"]; hasSearch { + delete(tool, "googleSearch") + droppedBuiltin = true + } + if _, hasCodeExecution := tool["codeExecution"]; hasCodeExecution { + delete(tool, "codeExecution") + droppedBuiltin = true + } + if len(tool) == 0 { + continue + } + filtered = append(filtered, tool) + } + if !droppedBuiltin { + return body, nil + } + + request["tools"] = filtered + if toolConfig, ok := request["toolConfig"].(map[string]any); ok { + delete(toolConfig, "includeServerSideToolInvocations") + delete(toolConfig, "include_server_side_tool_invocations") + if len(toolConfig) == 0 { + delete(request, "toolConfig") + } } - toolConfig["includeServerSideToolInvocations"] = true return json.Marshal(request) } diff --git a/backend/internal/service/antigravity_gateway_compat_test.go b/backend/internal/service/antigravity_gateway_compat_test.go index eb4f67ab6e..8912251c41 100644 --- a/backend/internal/service/antigravity_gateway_compat_test.go +++ b/backend/internal/service/antigravity_gateway_compat_test.go @@ -91,6 +91,7 @@ func newAntigravityCompatAccount(accountType string) *Account { "model_mapping": map[string]any{ "gemini-3.1-pro-high": "gemini-3.1-pro-high", "claude-sonnet-4-5": "claude-sonnet-4-5", + "claude-sonnet-4-6": "claude-sonnet-4-6", "claude-opus-4-6-thinking": "claude-opus-4-6-thinking", }, }, @@ -228,22 +229,27 @@ func TestAntigravityCompatRejectsUnsupportedAccountType(t *testing.T) { func TestBuildAntigravityCompatGeminiBody_ConfiguresMixedToolInvocations(t *testing.T) { svc := &AntigravityGatewayService{} tests := []struct { - name string - tools string - wantField bool + name string + tools string + wantFuncs bool + wantGoogle bool + wantServerFlag bool }{ { - name: "mixed server and client tools", - tools: `[{"name":"get_weather","input_schema":{"type":"object"}},{"type":"web_search_20250305","name":"web_search"}]`, - wantField: true, + name: "mixed server and client tools prefer client tools", + tools: `[{"name":"get_weather","input_schema":{"type":"object"}},{"type":"web_search_20250305","name":"web_search"}]`, + wantFuncs: true, + wantGoogle: false, }, { - name: "client tools only", - tools: `[{"name":"get_weather","input_schema":{"type":"object"}}]`, + name: "client tools only", + tools: `[{"name":"get_weather","input_schema":{"type":"object"}}]`, + wantFuncs: true, }, { - name: "server tools only", - tools: `[{"type":"web_search_20250305","name":"web_search"}]`, + name: "server tools only", + tools: `[{"type":"web_search_20250305","name":"web_search"}]`, + wantGoogle: true, }, } @@ -258,19 +264,38 @@ func TestBuildAntigravityCompatGeminiBody_ConfiguresMixedToolInvocations(t *test require.NoError(t, json.Unmarshal(body, &wrapped)) request, ok := wrapped["request"].(map[string]any) require.True(t, ok) + + tools, _ := request["tools"].([]any) + hasFuncs, hasGoogle := false, false + for _, raw := range tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if decls, ok := tool["functionDeclarations"].([]any); ok && len(decls) > 0 { + hasFuncs = true + } + if _, ok := tool["googleSearch"]; ok { + hasGoogle = true + } + } + require.Equal(t, tt.wantFuncs, hasFuncs) + require.Equal(t, tt.wantGoogle, hasGoogle) + toolConfig, exists := request["toolConfig"].(map[string]any) - if !tt.wantField { - require.False(t, exists) + if !tt.wantServerFlag { + if exists { + require.NotContains(t, toolConfig, "includeServerSideToolInvocations") + } return } require.True(t, exists) require.Equal(t, true, toolConfig["includeServerSideToolInvocations"]) - require.NotContains(t, toolConfig, "include_server_side_tool_invocations") }) } } -func TestAntigravityCompatChatMixedBuiltInToolsEnableServerSideInvocations(t *testing.T) { +func TestAntigravityCompatChatMixedBuiltInToolsPreferClientFunctions(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{antigravityCompatSuccessResponse()}} svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, upstream) @@ -293,10 +318,43 @@ func TestAntigravityCompatChatMixedBuiltInToolsEnableServerSideInvocations(t *te require.NotNil(t, result) require.Len(t, upstream.requestBodies, 1) requestBody := upstream.requestBodies[0] - require.True(t, gjson.GetBytes(requestBody, "request.toolConfig.includeServerSideToolInvocations").Bool()) + require.False(t, gjson.GetBytes(requestBody, "request.toolConfig.includeServerSideToolInvocations").Exists()) require.Len(t, gjson.GetBytes(requestBody, "request.tools.0.functionDeclarations").Array(), 2) - require.True(t, gjson.GetBytes(requestBody, "request.tools.1.googleSearch").Exists()) - require.True(t, gjson.GetBytes(requestBody, "request.tools.2.codeExecution").Exists()) + // No built-in tool entries should remain once client functions are present. + for _, tool := range gjson.GetBytes(requestBody, "request.tools").Array() { + require.False(t, tool.Get("googleSearch").Exists()) + require.False(t, tool.Get("codeExecution").Exists()) + } +} + +func TestAntigravityCompatResponsesCodexWebSearchMixedWithFunctionsDropsBuiltins(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{antigravityCompatSuccessResponse()}} + svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, upstream) + body := []byte(`{ + "model":"claude-sonnet-4-6", + "input":"Reply with exactly: pong", + "stream":true, + "tools":[ + {"type":"function","name":"shell","description":"Run a shell command","parameters":{"type":"object","properties":{"command":{"type":"array","items":{"type":"string"}}},"required":["command"],"additionalProperties":false},"strict":true}, + {"type":"web_search"} + ] + }`) + c, _ := newAntigravityCompatContext(http.MethodPost, "/v1/responses", body) + + result, err := svc.ForwardAsResponses(context.Background(), c, newAntigravityCompatAccount(AccountTypeOAuth), body, nil) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.requestBodies, 1) + requestBody := upstream.requestBodies[0] + require.Equal(t, "agent", gjson.GetBytes(requestBody, "requestType").String()) + require.NotEqual(t, "gemini-2.5-flash", gjson.GetBytes(requestBody, "model").String()) + require.True(t, gjson.GetBytes(requestBody, "request.tools.0.functionDeclarations.#(name==\"shell\")").Exists()) + for _, tool := range gjson.GetBytes(requestBody, "request.tools").Array() { + require.False(t, tool.Get("googleSearch").Exists()) + } + require.False(t, gjson.GetBytes(requestBody, "request.toolConfig.includeServerSideToolInvocations").Exists()) } func TestAntigravityCompatPreservesChatTokenLimit(t *testing.T) { diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index 35824b51a7..5c84b746e1 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -131,6 +131,11 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Failed to clean schema: %v", err) } + // Antigravity v1internal rejects built-in + functionDeclarations mixes (#6464). + if reconciled, err := enableMixedGeminiToolInvocations(injectedBody); err == nil { + injectedBody = reconciled + } + // 包装请求 wrappedBody, err := s.wrapV1InternalRequest(projectID, mappedModel, injectedBody) if err != nil { diff --git a/backend/internal/service/antigravity_gateway_service_test.go b/backend/internal/service/antigravity_gateway_service_test.go index c463a870e5..dfca59619c 100644 --- a/backend/internal/service/antigravity_gateway_service_test.go +++ b/backend/internal/service/antigravity_gateway_service_test.go @@ -399,7 +399,7 @@ func TestAntigravityGatewayService_ForwardGemini_ImageUsesDefaultMappingAndOAuth require.Equal(t, []any{"TEXT", "IMAGE"}, generationConfig["responseModalities"]) } -func TestAntigravityGatewayService_ForwardGemini_PreservesServerSideToolInvocationConfig(t *testing.T) { +func TestAntigravityGatewayService_ForwardGemini_StripsBuiltinsWhenClientFunctionsPresent(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"object","additionalProperties":false}}]},{"googleSearch":{}}],"toolConfig":{"includeServerSideToolInvocations":true}}`) writer := httptest.NewRecorder() @@ -431,10 +431,16 @@ func TestAntigravityGatewayService_ForwardGemini_PreservesServerSideToolInvocati require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped)) request, ok := wrapped["request"].(map[string]any) require.True(t, ok) - toolConfig, ok := request["toolConfig"].(map[string]any) + tools, ok := request["tools"].([]any) require.True(t, ok) - require.Equal(t, true, toolConfig["includeServerSideToolInvocations"]) - require.NotContains(t, toolConfig, "include_server_side_tool_invocations") + require.Len(t, tools, 1) + tool, ok := tools[0].(map[string]any) + require.True(t, ok) + require.Contains(t, tool, "functionDeclarations") + require.NotContains(t, tool, "googleSearch") + if toolConfig, exists := request["toolConfig"].(map[string]any); exists { + require.NotContains(t, toolConfig, "includeServerSideToolInvocations") + } } func TestAntigravityGatewayService_ForwardGemini_MissingProjectReturnsLocalError(t *testing.T) {