feat: add antigravity web search support
This commit is contained in:
@@ -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"},
|
||||
|
||||
@@ -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{{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user