diff --git a/backend/internal/pkg/antigravity/gemini_types.go b/backend/internal/pkg/antigravity/gemini_types.go index f688332f..ad873901 100644 --- a/backend/internal/pkg/antigravity/gemini_types.go +++ b/backend/internal/pkg/antigravity/gemini_types.go @@ -143,9 +143,10 @@ type GeminiResponse struct { // GeminiCandidate Gemini 候选响应 type GeminiCandidate struct { - Content *GeminiContent `json:"content,omitempty"` - FinishReason string `json:"finishReason,omitempty"` - Index int `json:"index,omitempty"` + Content *GeminiContent `json:"content,omitempty"` + FinishReason string `json:"finishReason,omitempty"` + Index int `json:"index,omitempty"` + GroundingMetadata *GeminiGroundingMetadata `json:"groundingMetadata,omitempty"` } // GeminiUsageMetadata Gemini 用量元数据 @@ -156,6 +157,23 @@ type GeminiUsageMetadata struct { TotalTokenCount int `json:"totalTokenCount,omitempty"` } +// GeminiGroundingMetadata Gemini grounding 元数据(Web Search) +type GeminiGroundingMetadata struct { + WebSearchQueries []string `json:"webSearchQueries,omitempty"` + GroundingChunks []GeminiGroundingChunk `json:"groundingChunks,omitempty"` +} + +// GeminiGroundingChunk Gemini grounding chunk +type GeminiGroundingChunk struct { + Web *GeminiGroundingWeb `json:"web,omitempty"` +} + +// GeminiGroundingWeb Gemini grounding web 信息 +type GeminiGroundingWeb struct { + Title string `json:"title,omitempty"` + URI string `json:"uri,omitempty"` +} + // DefaultSafetySettings 默认安全设置(关闭所有过滤) var DefaultSafetySettings = []GeminiSafetySetting{ {Category: "HARM_CATEGORY_HARASSMENT", Threshold: "OFF"}, diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 9b703187..637a4ea8 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -54,6 +54,9 @@ func DefaultTransformOptions() TransformOptions { } } +// webSearchFallbackModel web_search 请求使用的降级模型 +const webSearchFallbackModel = "gemini-2.5-flash" + // TransformClaudeToGemini 将 Claude 请求转换为 v1internal Gemini 格式 func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel string) ([]byte, error) { return TransformClaudeToGeminiWithOptions(claudeReq, projectID, mappedModel, DefaultTransformOptions()) @@ -64,12 +67,23 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map // 用于存储 tool_use id -> name 映射 toolIDToName := make(map[string]string) + // 检测是否有 web_search 工具 + hasWebSearchTool := hasWebSearchTool(claudeReq.Tools) + requestType := "agent" + targetModel := mappedModel + if hasWebSearchTool { + requestType = "web_search" + if targetModel != webSearchFallbackModel { + targetModel = webSearchFallbackModel + } + } + // 检测是否启用 thinking isThinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled" // 只有 Gemini 模型支持 dummy thought workaround // Claude 模型通过 Vertex/Google API 需要有效的 thought signatures - allowDummyThought := strings.HasPrefix(mappedModel, "gemini-") + allowDummyThought := strings.HasPrefix(targetModel, "gemini-") // 1. 构建 contents contents, strippedThinking, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, allowDummyThought) @@ -89,6 +103,11 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map reqCopy.Thinking = nil reqForConfig = &reqCopy } + if targetModel != "" && targetModel != reqForConfig.Model { + reqCopy := *reqForConfig + reqCopy.Model = targetModel + reqForConfig = &reqCopy + } generationConfig := buildGenerationConfig(reqForConfig) // 4. 构建 tools @@ -127,8 +146,8 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map Project: projectID, RequestID: "agent-" + uuid.New().String(), UserAgent: "antigravity", // 固定值,与官方客户端一致 - RequestType: "agent", - Model: mappedModel, + RequestType: requestType, + Model: targetModel, Request: innerRequest, } @@ -513,37 +532,43 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig { return config } +func hasWebSearchTool(tools []ClaudeTool) bool { + for _, tool := range tools { + if isWebSearchTool(tool) { + return true + } + } + return false +} + +func isWebSearchTool(tool ClaudeTool) bool { + if strings.HasPrefix(tool.Type, "web_search") || tool.Type == "google_search" { + return true + } + + name := strings.TrimSpace(tool.Name) + switch name { + case "web_search", "google_search", "web_search_20250305": + return true + default: + return false + } +} + // buildTools 构建 tools func buildTools(tools []ClaudeTool) []GeminiToolDeclaration { if len(tools) == 0 { return nil } - // 检查是否有 web_search 工具 - hasWebSearch := false - for _, tool := range tools { - if tool.Name == "web_search" { - hasWebSearch = true - break - } - } - - if hasWebSearch { - // Web Search 工具映射 - return []GeminiToolDeclaration{{ - GoogleSearch: &GeminiGoogleSearch{ - EnhancedContent: &GeminiEnhancedContent{ - ImageSearch: &GeminiImageSearch{ - MaxResultCount: 5, - }, - }, - }, - }} - } + hasWebSearch := hasWebSearchTool(tools) // 普通工具 var funcDecls []GeminiFunctionDecl for _, tool := range tools { + if isWebSearchTool(tool) { + continue + } // 跳过无效工具名称 if strings.TrimSpace(tool.Name) == "" { log.Printf("Warning: skipping tool with empty name") @@ -586,7 +611,20 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration { } if len(funcDecls) == 0 { - return nil + if !hasWebSearch { + return nil + } + + // Web Search 工具映射 + return []GeminiToolDeclaration{{ + GoogleSearch: &GeminiGoogleSearch{ + EnhancedContent: &GeminiEnhancedContent{ + ImageSearch: &GeminiImageSearch{ + MaxResultCount: 5, + }, + }, + }, + }} } return []GeminiToolDeclaration{{ diff --git a/backend/internal/pkg/antigravity/response_transformer.go b/backend/internal/pkg/antigravity/response_transformer.go index cd7f5f80..b99e6b3d 100644 --- a/backend/internal/pkg/antigravity/response_transformer.go +++ b/backend/internal/pkg/antigravity/response_transformer.go @@ -3,6 +3,7 @@ package antigravity import ( "encoding/json" "fmt" + "strings" ) // TransformGeminiToClaude 将 Gemini 响应转换为 Claude 格式(非流式) @@ -63,6 +64,12 @@ func (p *NonStreamingProcessor) Process(geminiResp *GeminiResponse, responseID, p.processPart(&part) } + if len(geminiResp.Candidates) > 0 { + if grounding := geminiResp.Candidates[0].GroundingMetadata; grounding != nil { + p.processGrounding(grounding) + } + } + // 刷新剩余内容 p.flushThinking() p.flushText() @@ -190,6 +197,18 @@ func (p *NonStreamingProcessor) processPart(part *GeminiPart) { } } +func (p *NonStreamingProcessor) processGrounding(grounding *GeminiGroundingMetadata) { + groundingText := buildGroundingText(grounding) + if groundingText == "" { + return + } + + p.flushThinking() + p.flushText() + p.textBuilder += groundingText + p.flushText() +} + // flushText 刷新 text builder func (p *NonStreamingProcessor) flushText() { if p.textBuilder == "" { @@ -262,6 +281,44 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon } } +func buildGroundingText(grounding *GeminiGroundingMetadata) string { + if grounding == nil { + return "" + } + + var builder strings.Builder + + if len(grounding.WebSearchQueries) > 0 { + builder.WriteString("\n\n---\nWeb search queries: ") + builder.WriteString(strings.Join(grounding.WebSearchQueries, ", ")) + } + + if len(grounding.GroundingChunks) > 0 { + var links []string + for i, chunk := range grounding.GroundingChunks { + if chunk.Web == nil { + continue + } + title := strings.TrimSpace(chunk.Web.Title) + if title == "" { + title = "Source" + } + uri := strings.TrimSpace(chunk.Web.URI) + if uri == "" { + uri = "#" + } + links = append(links, fmt.Sprintf("[%d] [%s](%s)", i+1, title, uri)) + } + + if len(links) > 0 { + builder.WriteString("\n\nSources:\n") + builder.WriteString(strings.Join(links, "\n")) + } + } + + return builder.String() +} + // generateRandomID 生成随机 ID func generateRandomID() string { const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" diff --git a/backend/internal/pkg/antigravity/stream_transformer.go b/backend/internal/pkg/antigravity/stream_transformer.go index 9fe68a11..da0c6f97 100644 --- a/backend/internal/pkg/antigravity/stream_transformer.go +++ b/backend/internal/pkg/antigravity/stream_transformer.go @@ -27,6 +27,8 @@ type StreamingProcessor struct { pendingSignature string trailingSignature string originalModel string + webSearchQueries []string + groundingChunks []GeminiGroundingChunk // 累计 usage inputTokens int @@ -93,6 +95,10 @@ func (p *StreamingProcessor) ProcessLine(line string) []byte { } } + if len(geminiResp.Candidates) > 0 { + p.captureGrounding(geminiResp.Candidates[0].GroundingMetadata) + } + // 检查是否结束 if len(geminiResp.Candidates) > 0 { finishReason := geminiResp.Candidates[0].FinishReason @@ -200,6 +206,20 @@ func (p *StreamingProcessor) processPart(part *GeminiPart) []byte { return result.Bytes() } +func (p *StreamingProcessor) captureGrounding(grounding *GeminiGroundingMetadata) { + if grounding == nil { + return + } + + if len(grounding.WebSearchQueries) > 0 && len(p.webSearchQueries) == 0 { + p.webSearchQueries = append([]string(nil), grounding.WebSearchQueries...) + } + + if len(grounding.GroundingChunks) > 0 && len(p.groundingChunks) == 0 { + p.groundingChunks = append([]GeminiGroundingChunk(nil), grounding.GroundingChunks...) + } +} + // processThinking 处理 thinking func (p *StreamingProcessor) processThinking(text, signature string) []byte { var result bytes.Buffer @@ -417,6 +437,23 @@ func (p *StreamingProcessor) emitFinish(finishReason string) []byte { p.trailingSignature = "" } + if len(p.webSearchQueries) > 0 || len(p.groundingChunks) > 0 { + groundingText := buildGroundingText(&GeminiGroundingMetadata{ + WebSearchQueries: p.webSearchQueries, + GroundingChunks: p.groundingChunks, + }) + if groundingText != "" { + _, _ = result.Write(p.startBlock(BlockTypeText, map[string]any{ + "type": "text", + "text": "", + })) + _, _ = result.Write(p.emitDelta("text_delta", map[string]any{ + "text": groundingText, + })) + _, _ = result.Write(p.endBlock()) + } + } + // 确定 stop_reason stopReason := "end_turn" if p.usedTool {