feat: add antigravity web search support
This commit is contained in:
@@ -143,9 +143,10 @@ type GeminiResponse struct {
|
|||||||
|
|
||||||
// GeminiCandidate Gemini 候选响应
|
// GeminiCandidate Gemini 候选响应
|
||||||
type GeminiCandidate struct {
|
type GeminiCandidate struct {
|
||||||
Content *GeminiContent `json:"content,omitempty"`
|
Content *GeminiContent `json:"content,omitempty"`
|
||||||
FinishReason string `json:"finishReason,omitempty"`
|
FinishReason string `json:"finishReason,omitempty"`
|
||||||
Index int `json:"index,omitempty"`
|
Index int `json:"index,omitempty"`
|
||||||
|
GroundingMetadata *GeminiGroundingMetadata `json:"groundingMetadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeminiUsageMetadata Gemini 用量元数据
|
// GeminiUsageMetadata Gemini 用量元数据
|
||||||
@@ -156,6 +157,23 @@ type GeminiUsageMetadata struct {
|
|||||||
TotalTokenCount int `json:"totalTokenCount,omitempty"`
|
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 默认安全设置(关闭所有过滤)
|
// DefaultSafetySettings 默认安全设置(关闭所有过滤)
|
||||||
var DefaultSafetySettings = []GeminiSafetySetting{
|
var DefaultSafetySettings = []GeminiSafetySetting{
|
||||||
{Category: "HARM_CATEGORY_HARASSMENT", Threshold: "OFF"},
|
{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 格式
|
// TransformClaudeToGemini 将 Claude 请求转换为 v1internal Gemini 格式
|
||||||
func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel string) ([]byte, error) {
|
func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel string) ([]byte, error) {
|
||||||
return TransformClaudeToGeminiWithOptions(claudeReq, projectID, mappedModel, DefaultTransformOptions())
|
return TransformClaudeToGeminiWithOptions(claudeReq, projectID, mappedModel, DefaultTransformOptions())
|
||||||
@@ -64,12 +67,23 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
|||||||
// 用于存储 tool_use id -> name 映射
|
// 用于存储 tool_use id -> name 映射
|
||||||
toolIDToName := make(map[string]string)
|
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
|
// 检测是否启用 thinking
|
||||||
isThinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled"
|
isThinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled"
|
||||||
|
|
||||||
// 只有 Gemini 模型支持 dummy thought workaround
|
// 只有 Gemini 模型支持 dummy thought workaround
|
||||||
// Claude 模型通过 Vertex/Google API 需要有效的 thought signatures
|
// Claude 模型通过 Vertex/Google API 需要有效的 thought signatures
|
||||||
allowDummyThought := strings.HasPrefix(mappedModel, "gemini-")
|
allowDummyThought := strings.HasPrefix(targetModel, "gemini-")
|
||||||
|
|
||||||
// 1. 构建 contents
|
// 1. 构建 contents
|
||||||
contents, strippedThinking, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, allowDummyThought)
|
contents, strippedThinking, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, allowDummyThought)
|
||||||
@@ -89,6 +103,11 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
|||||||
reqCopy.Thinking = nil
|
reqCopy.Thinking = nil
|
||||||
reqForConfig = &reqCopy
|
reqForConfig = &reqCopy
|
||||||
}
|
}
|
||||||
|
if targetModel != "" && targetModel != reqForConfig.Model {
|
||||||
|
reqCopy := *reqForConfig
|
||||||
|
reqCopy.Model = targetModel
|
||||||
|
reqForConfig = &reqCopy
|
||||||
|
}
|
||||||
generationConfig := buildGenerationConfig(reqForConfig)
|
generationConfig := buildGenerationConfig(reqForConfig)
|
||||||
|
|
||||||
// 4. 构建 tools
|
// 4. 构建 tools
|
||||||
@@ -127,8 +146,8 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
|||||||
Project: projectID,
|
Project: projectID,
|
||||||
RequestID: "agent-" + uuid.New().String(),
|
RequestID: "agent-" + uuid.New().String(),
|
||||||
UserAgent: "antigravity", // 固定值,与官方客户端一致
|
UserAgent: "antigravity", // 固定值,与官方客户端一致
|
||||||
RequestType: "agent",
|
RequestType: requestType,
|
||||||
Model: mappedModel,
|
Model: targetModel,
|
||||||
Request: innerRequest,
|
Request: innerRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,37 +532,43 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
|
|||||||
return config
|
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
|
// buildTools 构建 tools
|
||||||
func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
||||||
if len(tools) == 0 {
|
if len(tools) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有 web_search 工具
|
hasWebSearch := hasWebSearchTool(tools)
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 普通工具
|
// 普通工具
|
||||||
var funcDecls []GeminiFunctionDecl
|
var funcDecls []GeminiFunctionDecl
|
||||||
for _, tool := range tools {
|
for _, tool := range tools {
|
||||||
|
if isWebSearchTool(tool) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// 跳过无效工具名称
|
// 跳过无效工具名称
|
||||||
if strings.TrimSpace(tool.Name) == "" {
|
if strings.TrimSpace(tool.Name) == "" {
|
||||||
log.Printf("Warning: skipping tool with empty name")
|
log.Printf("Warning: skipping tool with empty name")
|
||||||
@@ -586,7 +611,20 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(funcDecls) == 0 {
|
if len(funcDecls) == 0 {
|
||||||
return nil
|
if !hasWebSearch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web Search 工具映射
|
||||||
|
return []GeminiToolDeclaration{{
|
||||||
|
GoogleSearch: &GeminiGoogleSearch{
|
||||||
|
EnhancedContent: &GeminiEnhancedContent{
|
||||||
|
ImageSearch: &GeminiImageSearch{
|
||||||
|
MaxResultCount: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
return []GeminiToolDeclaration{{
|
return []GeminiToolDeclaration{{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package antigravity
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TransformGeminiToClaude 将 Gemini 响应转换为 Claude 格式(非流式)
|
// TransformGeminiToClaude 将 Gemini 响应转换为 Claude 格式(非流式)
|
||||||
@@ -63,6 +64,12 @@ func (p *NonStreamingProcessor) Process(geminiResp *GeminiResponse, responseID,
|
|||||||
p.processPart(&part)
|
p.processPart(&part)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(geminiResp.Candidates) > 0 {
|
||||||
|
if grounding := geminiResp.Candidates[0].GroundingMetadata; grounding != nil {
|
||||||
|
p.processGrounding(grounding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 刷新剩余内容
|
// 刷新剩余内容
|
||||||
p.flushThinking()
|
p.flushThinking()
|
||||||
p.flushText()
|
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
|
// flushText 刷新 text builder
|
||||||
func (p *NonStreamingProcessor) flushText() {
|
func (p *NonStreamingProcessor) flushText() {
|
||||||
if p.textBuilder == "" {
|
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
|
// generateRandomID 生成随机 ID
|
||||||
func generateRandomID() string {
|
func generateRandomID() string {
|
||||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type StreamingProcessor struct {
|
|||||||
pendingSignature string
|
pendingSignature string
|
||||||
trailingSignature string
|
trailingSignature string
|
||||||
originalModel string
|
originalModel string
|
||||||
|
webSearchQueries []string
|
||||||
|
groundingChunks []GeminiGroundingChunk
|
||||||
|
|
||||||
// 累计 usage
|
// 累计 usage
|
||||||
inputTokens int
|
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 {
|
if len(geminiResp.Candidates) > 0 {
|
||||||
finishReason := geminiResp.Candidates[0].FinishReason
|
finishReason := geminiResp.Candidates[0].FinishReason
|
||||||
@@ -200,6 +206,20 @@ func (p *StreamingProcessor) processPart(part *GeminiPart) []byte {
|
|||||||
return result.Bytes()
|
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
|
// processThinking 处理 thinking
|
||||||
func (p *StreamingProcessor) processThinking(text, signature string) []byte {
|
func (p *StreamingProcessor) processThinking(text, signature string) []byte {
|
||||||
var result bytes.Buffer
|
var result bytes.Buffer
|
||||||
@@ -417,6 +437,23 @@ func (p *StreamingProcessor) emitFinish(finishReason string) []byte {
|
|||||||
p.trailingSignature = ""
|
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
|
// 确定 stop_reason
|
||||||
stopReason := "end_turn"
|
stopReason := "end_turn"
|
||||||
if p.usedTool {
|
if p.usedTool {
|
||||||
|
|||||||
Reference in New Issue
Block a user