Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
502
backend/internal/translator/antigravity/claude/web_search.go
Normal file
502
backend/internal/translator/antigravity/claude/web_search.go
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
type webSearchGroundingSupport struct {
|
||||
StartIndex int64
|
||||
EndIndex int64
|
||||
Text string
|
||||
ChunkURLs []string
|
||||
ChunkTitle string
|
||||
}
|
||||
|
||||
type webSearchCitedTextBlock struct {
|
||||
Text string
|
||||
Citations []map[string]any
|
||||
}
|
||||
|
||||
const antigravityWebSearchSystemInstruction = "You are a search engine bot. You will be given a query from a user. Your task is to search the web for relevant information that will help the user. You MUST perform a web search. Do not respond or interact with the user, please respond as if they typed the query into a search bar."
|
||||
|
||||
func antigravitySupportsNativeGoogleSearch(model string) bool {
|
||||
return registry.AntigravityWebSearchModelFor(model) != ""
|
||||
}
|
||||
|
||||
func isClaudeTypedWebSearchToolType(toolType string) bool {
|
||||
return toolType == "web_search_20250305" || toolType == "web_search_20260209"
|
||||
}
|
||||
|
||||
func hasClaudeTypedWebSearchTool(payload []byte) bool {
|
||||
tools := gjson.GetBytes(payload, "tools")
|
||||
if !tools.IsArray() {
|
||||
return false
|
||||
}
|
||||
for _, tool := range tools.Array() {
|
||||
if isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasOnlyClaudeTypedWebSearchTools(payload []byte) bool {
|
||||
tools := gjson.GetBytes(payload, "tools")
|
||||
if !tools.IsArray() {
|
||||
return false
|
||||
}
|
||||
hasWebSearch := false
|
||||
for _, tool := range tools.Array() {
|
||||
if isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
|
||||
hasWebSearch = true
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return hasWebSearch
|
||||
}
|
||||
|
||||
func allowsClaudeWebSearchToolChoice(payload []byte) bool {
|
||||
toolChoice := gjson.GetBytes(payload, "tool_choice")
|
||||
if !toolChoice.Exists() {
|
||||
return true
|
||||
}
|
||||
if toolChoice.Type == gjson.String {
|
||||
switch toolChoice.String() {
|
||||
case "", "auto", "any":
|
||||
return true
|
||||
case "none":
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !toolChoice.IsObject() {
|
||||
return false
|
||||
}
|
||||
switch toolChoice.Get("type").String() {
|
||||
case "", "auto", "any":
|
||||
return true
|
||||
case "tool":
|
||||
return toolChoice.Get("name").String() == "web_search"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func shouldBuildAntigravityWebSearchRequest(model string, payload []byte) bool {
|
||||
return antigravitySupportsNativeGoogleSearch(model) &&
|
||||
hasOnlyClaudeTypedWebSearchTools(payload) &&
|
||||
allowsClaudeWebSearchToolChoice(payload)
|
||||
}
|
||||
|
||||
func buildAntigravityWebSearchRequest(model string, payload []byte) []byte {
|
||||
query := extractClaudeWebSearchQuery(payload)
|
||||
maxResultCount := extractClaudeWebSearchMaxUses(payload)
|
||||
includedDomains := extractClaudeWebSearchAllowedDomains(payload)
|
||||
out := []byte(`{"model":"","requestType":"web_search","request":{"contents":[{"role":"user","parts":[{"text":""}]}],"systemInstruction":{"role":"user","parts":[{"text":""}]},"tools":[{"googleSearch":{"enhancedContent":{"imageSearch":{"maxResultCount":5}}}}],"generationConfig":{"candidateCount":1}}}`)
|
||||
out, _ = sjson.SetBytes(out, "model", model)
|
||||
out, _ = sjson.SetBytes(out, "request.contents.0.parts.0.text", query)
|
||||
out, _ = sjson.SetBytes(out, "request.systemInstruction.parts.0.text", antigravityWebSearchSystemInstruction)
|
||||
out, _ = sjson.SetBytes(out, "request.tools.0.googleSearch.enhancedContent.imageSearch.maxResultCount", maxResultCount)
|
||||
if len(includedDomains) > 0 {
|
||||
if domainsJSON, err := json.Marshal(includedDomains); err == nil {
|
||||
out, _ = sjson.SetRawBytes(out, "request.tools.0.googleSearch.includedDomains", domainsJSON)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractClaudeWebSearchMaxUses(payload []byte) int64 {
|
||||
const defaultMaxResultCount int64 = 5
|
||||
|
||||
tools := gjson.GetBytes(payload, "tools")
|
||||
if !tools.IsArray() {
|
||||
return defaultMaxResultCount
|
||||
}
|
||||
for _, tool := range tools.Array() {
|
||||
if !isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
|
||||
continue
|
||||
}
|
||||
maxUses := tool.Get("max_uses").Int()
|
||||
if maxUses > 0 {
|
||||
return maxUses
|
||||
}
|
||||
}
|
||||
return defaultMaxResultCount
|
||||
}
|
||||
|
||||
func extractClaudeWebSearchAllowedDomains(payload []byte) []string {
|
||||
tools := gjson.GetBytes(payload, "tools")
|
||||
if !tools.IsArray() {
|
||||
return nil
|
||||
}
|
||||
for _, tool := range tools.Array() {
|
||||
if !isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
|
||||
continue
|
||||
}
|
||||
allowedDomains := tool.Get("allowed_domains")
|
||||
if !allowedDomains.IsArray() {
|
||||
return nil
|
||||
}
|
||||
domains := make([]string, 0, len(allowedDomains.Array()))
|
||||
for _, domain := range allowedDomains.Array() {
|
||||
if domain.Type != gjson.String {
|
||||
continue
|
||||
}
|
||||
if trimmed := strings.TrimSpace(domain.String()); trimmed != "" {
|
||||
domains = append(domains, trimmed)
|
||||
}
|
||||
}
|
||||
return domains
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractClaudeWebSearchQuery(payload []byte) string {
|
||||
messages := gjson.GetBytes(payload, "messages")
|
||||
if !messages.IsArray() {
|
||||
return ""
|
||||
}
|
||||
messageResults := messages.Array()
|
||||
for i := len(messageResults) - 1; i >= 0; i-- {
|
||||
message := messageResults[i]
|
||||
if role := message.Get("role").String(); role != "" && role != "user" {
|
||||
continue
|
||||
}
|
||||
if query := extractClaudeTextContent(message.Get("content")); query != "" {
|
||||
return query
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractClaudeTextContent(content gjson.Result) string {
|
||||
if content.Type == gjson.String {
|
||||
return strings.TrimSpace(content.String())
|
||||
}
|
||||
if !content.IsArray() {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, part := range content.Array() {
|
||||
if text := strings.TrimSpace(part.Get("text").String()); text != "" {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(text)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func hasAntigravityGoogleSearchTool(payload []byte) bool {
|
||||
tools := gjson.GetBytes(payload, "request.tools")
|
||||
if !tools.IsArray() {
|
||||
return false
|
||||
}
|
||||
for _, tool := range tools.Array() {
|
||||
if tool.Get("googleSearch").Exists() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldTranslateWebSearchGrounding(originalRequestRawJSON, requestRawJSON []byte) bool {
|
||||
return hasClaudeTypedWebSearchTool(originalRequestRawJSON) && hasAntigravityGoogleSearchTool(requestRawJSON)
|
||||
}
|
||||
|
||||
func antigravityGroundingMetadata(root gjson.Result) gjson.Result {
|
||||
groundingMetadata := root.Get("response.candidates.0.groundingMetadata")
|
||||
if groundingMetadata.Exists() {
|
||||
return groundingMetadata
|
||||
}
|
||||
return root.Get("candidates.0.groundingMetadata")
|
||||
}
|
||||
|
||||
func antigravityTextContent(root gjson.Result) string {
|
||||
var textBuilder strings.Builder
|
||||
parts := root.Get("response.candidates.0.content.parts")
|
||||
if !parts.IsArray() {
|
||||
parts = root.Get("candidates.0.content.parts")
|
||||
}
|
||||
if parts.IsArray() {
|
||||
for _, part := range parts.Array() {
|
||||
if text := part.Get("text"); text.Exists() {
|
||||
textBuilder.WriteString(text.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
return textBuilder.String()
|
||||
}
|
||||
|
||||
func antigravityUsageTokens(root gjson.Result) (int64, int64) {
|
||||
usage := root.Get("response.usageMetadata")
|
||||
if !usage.Exists() {
|
||||
usage = root.Get("usageMetadata")
|
||||
}
|
||||
inputTokens := usage.Get("promptTokenCount").Int()
|
||||
outputTokens := usage.Get("candidatesTokenCount").Int() + usage.Get("thoughtsTokenCount").Int()
|
||||
if outputTokens == 0 {
|
||||
totalTokens := usage.Get("totalTokenCount").Int()
|
||||
if totalTokens > 0 {
|
||||
outputTokens = totalTokens - inputTokens
|
||||
if outputTokens < 0 {
|
||||
outputTokens = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return inputTokens, outputTokens
|
||||
}
|
||||
|
||||
func webSearchQueryFromGrounding(groundingMetadata gjson.Result) string {
|
||||
if queries := groundingMetadata.Get("webSearchQueries"); queries.IsArray() && len(queries.Array()) > 0 {
|
||||
return queries.Array()[0].String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func webSearchResultsFromGrounding(groundingMetadata gjson.Result) []byte {
|
||||
results := []byte(`[]`)
|
||||
groundingChunks := groundingMetadata.Get("groundingChunks")
|
||||
if !groundingChunks.IsArray() {
|
||||
return results
|
||||
}
|
||||
seenURLs := make(map[string]struct{})
|
||||
for _, chunk := range groundingChunks.Array() {
|
||||
web := chunk.Get("web")
|
||||
if !web.Exists() {
|
||||
continue
|
||||
}
|
||||
uri := strings.TrimSpace(web.Get("uri").String())
|
||||
if uri == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenURLs[uri]; ok {
|
||||
continue
|
||||
}
|
||||
seenURLs[uri] = struct{}{}
|
||||
|
||||
result := []byte(`{"type":"web_search_result","page_age":null}`)
|
||||
if title := web.Get("title"); title.Exists() {
|
||||
result, _ = sjson.SetBytes(result, "title", title.String())
|
||||
}
|
||||
result, _ = sjson.SetBytes(result, "url", uri)
|
||||
results, _ = sjson.SetRawBytes(results, "-1", result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func parseWebSearchGroundingSupports(groundingMetadata gjson.Result) []webSearchGroundingSupport {
|
||||
groundingChunks := groundingMetadata.Get("groundingChunks")
|
||||
if !groundingChunks.IsArray() {
|
||||
return nil
|
||||
}
|
||||
chunks := groundingChunks.Array()
|
||||
chunkData := make([]struct {
|
||||
URL string
|
||||
Title string
|
||||
}, len(chunks))
|
||||
for i, chunk := range chunks {
|
||||
web := chunk.Get("web")
|
||||
if web.Exists() {
|
||||
chunkData[i].URL = web.Get("uri").String()
|
||||
chunkData[i].Title = web.Get("title").String()
|
||||
}
|
||||
}
|
||||
|
||||
groundingSupports := groundingMetadata.Get("groundingSupports")
|
||||
if !groundingSupports.IsArray() {
|
||||
return nil
|
||||
}
|
||||
supports := make([]webSearchGroundingSupport, 0, len(groundingSupports.Array()))
|
||||
for _, support := range groundingSupports.Array() {
|
||||
segment := support.Get("segment")
|
||||
if !segment.Exists() {
|
||||
continue
|
||||
}
|
||||
parsed := webSearchGroundingSupport{
|
||||
StartIndex: segment.Get("startIndex").Int(),
|
||||
EndIndex: segment.Get("endIndex").Int(),
|
||||
Text: segment.Get("text").String(),
|
||||
}
|
||||
if chunkIndices := support.Get("groundingChunkIndices"); chunkIndices.IsArray() {
|
||||
for _, idx := range chunkIndices.Array() {
|
||||
chunkIndex := int(idx.Int())
|
||||
if chunkIndex < 0 || chunkIndex >= len(chunkData) {
|
||||
continue
|
||||
}
|
||||
parsed.ChunkURLs = append(parsed.ChunkURLs, chunkData[chunkIndex].URL)
|
||||
if parsed.ChunkTitle == "" {
|
||||
parsed.ChunkTitle = chunkData[chunkIndex].Title
|
||||
}
|
||||
}
|
||||
}
|
||||
supports = append(supports, parsed)
|
||||
}
|
||||
return supports
|
||||
}
|
||||
|
||||
func buildWebSearchCitedTextBlocks(textContent string, supports []webSearchGroundingSupport) []webSearchCitedTextBlock {
|
||||
if len(supports) == 0 {
|
||||
if textContent == "" {
|
||||
return nil
|
||||
}
|
||||
return []webSearchCitedTextBlock{{Text: textContent}}
|
||||
}
|
||||
|
||||
textBytes := []byte(textContent)
|
||||
blocks := make([]webSearchCitedTextBlock, 0, len(supports)+1)
|
||||
lastEnd := int64(0)
|
||||
for _, support := range supports {
|
||||
if support.EndIndex <= lastEnd {
|
||||
continue
|
||||
}
|
||||
if support.StartIndex > lastEnd {
|
||||
start := int(lastEnd)
|
||||
end := min(int(support.StartIndex), len(textBytes))
|
||||
if start < end {
|
||||
blocks = append(blocks, webSearchCitedTextBlock{Text: string(textBytes[start:end])})
|
||||
}
|
||||
}
|
||||
|
||||
citedStart := support.StartIndex
|
||||
if citedStart < lastEnd {
|
||||
citedStart = lastEnd
|
||||
}
|
||||
citedText := ""
|
||||
if citedStart < support.EndIndex {
|
||||
start := min(int(citedStart), len(textBytes))
|
||||
end := min(int(support.EndIndex), len(textBytes))
|
||||
if start < end {
|
||||
citedText = string(textBytes[start:end])
|
||||
}
|
||||
}
|
||||
if citedText != "" && len(support.ChunkURLs) > 0 {
|
||||
citation := map[string]any{
|
||||
"type": "web_search_result_location",
|
||||
"cited_text": citedText,
|
||||
"url": support.ChunkURLs[0],
|
||||
"title": support.ChunkTitle,
|
||||
}
|
||||
blocks = append(blocks, webSearchCitedTextBlock{
|
||||
Text: citedText,
|
||||
Citations: []map[string]any{citation},
|
||||
})
|
||||
}
|
||||
if support.EndIndex > lastEnd {
|
||||
lastEnd = support.EndIndex
|
||||
}
|
||||
}
|
||||
if int(lastEnd) < len(textBytes) {
|
||||
blocks = append(blocks, webSearchCitedTextBlock{Text: string(textBytes[lastEnd:])})
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func buildClaudeWebSearchContent(toolUseID string, textContent string, groundingMetadata gjson.Result) []byte {
|
||||
content := []byte(`[]`)
|
||||
|
||||
serverToolUse := []byte(`{"type":"server_tool_use","id":"","name":"web_search","input":{}}`)
|
||||
serverToolUse, _ = sjson.SetBytes(serverToolUse, "id", toolUseID)
|
||||
if query := webSearchQueryFromGrounding(groundingMetadata); query != "" {
|
||||
serverToolUse, _ = sjson.SetBytes(serverToolUse, "input.query", query)
|
||||
}
|
||||
content, _ = sjson.SetRawBytes(content, "-1", serverToolUse)
|
||||
|
||||
webSearchToolResult := []byte(`{"type":"web_search_tool_result","tool_use_id":"","content":[]}`)
|
||||
webSearchToolResult, _ = sjson.SetBytes(webSearchToolResult, "tool_use_id", toolUseID)
|
||||
webSearchToolResult, _ = sjson.SetRawBytes(webSearchToolResult, "content", webSearchResultsFromGrounding(groundingMetadata))
|
||||
content, _ = sjson.SetRawBytes(content, "-1", webSearchToolResult)
|
||||
|
||||
for _, block := range buildWebSearchCitedTextBlocks(textContent, parseWebSearchGroundingSupports(groundingMetadata)) {
|
||||
if block.Text == "" {
|
||||
continue
|
||||
}
|
||||
textBlock := []byte(`{"type":"text","text":""}`)
|
||||
textBlock, _ = sjson.SetBytes(textBlock, "text", block.Text)
|
||||
if len(block.Citations) > 0 {
|
||||
citationsJSON, _ := json.Marshal(block.Citations)
|
||||
textBlock, _ = sjson.SetRawBytes(textBlock, "citations", citationsJSON)
|
||||
}
|
||||
content, _ = sjson.SetRawBytes(content, "-1", textBlock)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
func appendClaudeWebSearchStreamBlocks(appendEvent func(string, string), startIndex int, toolUseID string, textContent string, groundingMetadata gjson.Result) int {
|
||||
contentIndex := startIndex
|
||||
|
||||
serverToolUseStart := fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"server_tool_use","id":"%s","name":"web_search","input":{}}}`,
|
||||
contentIndex, toolUseID)
|
||||
appendEvent("content_block_start", serverToolUseStart)
|
||||
if query := webSearchQueryFromGrounding(groundingMetadata); query != "" {
|
||||
queryJSON, _ := sjson.Set(`{}`, "query", query)
|
||||
inputDelta := fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, contentIndex)
|
||||
inputDelta, _ = sjson.Set(inputDelta, "delta.partial_json", queryJSON)
|
||||
appendEvent("content_block_delta", inputDelta)
|
||||
}
|
||||
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, contentIndex))
|
||||
contentIndex++
|
||||
|
||||
webSearchToolResultStart := fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"web_search_tool_result","tool_use_id":"%s","content":[]}}`,
|
||||
contentIndex, toolUseID)
|
||||
webSearchToolResultStart, _ = sjson.SetRaw(webSearchToolResultStart, "content_block.content", string(webSearchResultsFromGrounding(groundingMetadata)))
|
||||
appendEvent("content_block_start", webSearchToolResultStart)
|
||||
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, contentIndex))
|
||||
contentIndex++
|
||||
|
||||
for _, block := range buildWebSearchCitedTextBlocks(textContent, parseWebSearchGroundingSupports(groundingMetadata)) {
|
||||
if block.Text == "" {
|
||||
continue
|
||||
}
|
||||
textBlockStart := fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, contentIndex)
|
||||
if len(block.Citations) > 0 {
|
||||
textBlockStart = fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"citations":[],"type":"text","text":""}}`, contentIndex)
|
||||
}
|
||||
appendEvent("content_block_start", textBlockStart)
|
||||
for _, citation := range block.Citations {
|
||||
citationJSON, _ := json.Marshal(citation)
|
||||
citationDelta := fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"citations_delta","citation":%s}}`, contentIndex, string(citationJSON))
|
||||
appendEvent("content_block_delta", citationDelta)
|
||||
}
|
||||
for _, chunk := range splitRunesForWebSearch(block.Text, 50) {
|
||||
textDelta := fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, contentIndex)
|
||||
textDelta, _ = sjson.Set(textDelta, "delta.text", chunk)
|
||||
appendEvent("content_block_delta", textDelta)
|
||||
}
|
||||
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, contentIndex))
|
||||
contentIndex++
|
||||
}
|
||||
|
||||
return contentIndex
|
||||
}
|
||||
|
||||
func splitRunesForWebSearch(text string, chunkSize int) []string {
|
||||
if chunkSize <= 0 || text == "" {
|
||||
return nil
|
||||
}
|
||||
runes := []rune(text)
|
||||
chunks := make([]string, 0, (len(runes)+chunkSize-1)/chunkSize)
|
||||
for start := 0; start < len(runes); start += chunkSize {
|
||||
end := start + chunkSize
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
chunks = append(chunks, string(runes[start:end]))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func newClaudeWebSearchToolUseID() string {
|
||||
return fmt.Sprintf("srvtoolu_%d", time.Now().UnixNano())
|
||||
}
|
||||
Loading…
Reference in a new issue