feat(antigravity): bridge Claude WebSearch to native googleSearch

Add a native Antigravity WebSearch path for Claude typed WebSearch requests.

Detect Claude Messages requests whose tools are only typed WebSearch tools
(web_search_20250305 / web_search_20260209), and convert them into an
Antigravity requestType=web_search payload instead of sending the request
through the normal tool-calling path.

Preserve the user's requested model. The native path is enabled only when that
Antigravity model is known to support Google Search. Capability data fetched
from Antigravity model info is used only as an enhancement to the local model
registry, not as a replacement for the existing registry fallback behavior.
Unsupported models keep the existing Antigravity request behavior and are not
silently rerouted to another web-search-capable model.

Translate Claude WebSearch request options to the verified Antigravity
googleSearch shape:
- max_uses -> googleSearch.enhancedContent.imageSearch.maxResultCount
- allowed_domains -> googleSearch.includedDomains

Leave blocked_domains and user_location unmapped because the Antigravity
googleSearch request shape has no verified equivalent for them. This avoids
sending speculative fields or pretending unsupported Claude WebSearch options
are enforced upstream.

Translate Antigravity web-search responses back into Claude-compatible output:
server_tool_use blocks, web_search_tool_result blocks, cited text blocks,
grounding URLs, and usage-compatible stream/non-stream responses.

Cover the behavior with tests for request conversion, response conversion,
grounding URL resolution, domain filter mapping, fetched capability hints,
excluded-model handling, and unsupported-model behavior.
This commit is contained in:
sususu98
2026-06-10 17:57:33 +08:00
parent 6a0b198c7e
commit 48dcadd9ef
16 changed files with 1637 additions and 12 deletions

View File

@@ -271,6 +271,46 @@ func validateAntigravityRequestSignatures(from sdktranslator.Format, rawJSON []b
return rawJSON, nil
}
func hasAntigravityClaudeTypedWebSearchTool(payload []byte) bool {
tools := gjson.GetBytes(payload, "tools")
if !tools.IsArray() {
return false
}
for _, tool := range tools.Array() {
switch tool.Get("type").String() {
case "web_search_20250305", "web_search_20260209":
return true
}
}
return false
}
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 shouldResolveAntigravityWebSearchGroundingURLs(from sdktranslator.Format, originalRequestRawJSON, requestRawJSON []byte) bool {
return from.String() == "claude" &&
hasAntigravityClaudeTypedWebSearchTool(originalRequestRawJSON) &&
hasAntigravityGoogleSearchTool(requestRawJSON)
}
func (e *AntigravityExecutor) resolveWebSearchGroundingURLs(ctx context.Context, auth *cliproxyauth.Auth, from sdktranslator.Format, originalRequestRawJSON, requestRawJSON, responseRawJSON []byte) []byte {
if !shouldResolveAntigravityWebSearchGroundingURLs(from, originalRequestRawJSON, requestRawJSON) {
return responseRawJSON
}
return helps.ResolveAntigravityGroundingURLs(ctx, e.cfg, auth, responseRawJSON)
}
func countClaudeThinkingBlocks(rawJSON []byte) int {
messages := gjson.GetBytes(rawJSON, "messages")
if !messages.IsArray() {
@@ -709,6 +749,7 @@ attemptLoop:
if useCredits {
clearAntigravityCreditsFailureState(auth)
}
bodyBytes = e.resolveWebSearchGroundingURLs(ctx, auth, from, originalPayload, translated, bodyBytes)
reporter.Publish(ctx, helps.ParseAntigravityUsage(bodyBytes))
var param any
converted := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, bodyBytes, &param)
@@ -973,6 +1014,7 @@ attemptLoop:
}
resp = cliproxyexecutor.Response{Payload: e.convertStreamToNonStream(buffer.Bytes())}
resp.Payload = e.resolveWebSearchGroundingURLs(ctx, auth, from, originalPayload, translated, resp.Payload)
reporter.Publish(ctx, helps.ParseAntigravityUsage(resp.Payload))
var param any
converted := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, resp.Payload, &param)
@@ -1414,6 +1456,7 @@ attemptLoop:
reporter.Publish(ctx, detail)
}
payload = e.resolveWebSearchGroundingURLs(ctx, auth, from, originalPayload, translated, payload)
chunks := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, bytes.Clone(payload), &param)
for i := range chunks {
select {
@@ -2473,14 +2516,15 @@ func geminiToAntigravity(modelName string, payload []byte, projectID string) []b
template, _ = sjson.SetBytes(template, "userAgent", "antigravity")
isImageModel := strings.Contains(modelName, "image")
var reqType string
if isImageModel {
reqType = "image_gen"
} else {
reqType = "agent"
reqType := strings.TrimSpace(gjson.GetBytes(template, "requestType").String())
if reqType == "" {
if isImageModel {
reqType = "image_gen"
} else {
reqType = "agent"
}
template, _ = sjson.SetBytes(template, "requestType", reqType)
}
template, _ = sjson.SetBytes(template, "requestType", reqType)
if projectID != "" {
template, _ = sjson.SetBytes(template, "project", projectID)
@@ -2490,7 +2534,7 @@ func geminiToAntigravity(modelName string, payload []byte, projectID string) []b
if isImageModel {
template, _ = sjson.SetBytes(template, "requestId", generateImageGenRequestID())
} else {
} else if reqType != "web_search" {
template, _ = sjson.SetBytes(template, "requestId", generateRequestID())
template, _ = sjson.SetBytes(template, "request.sessionId", generateStableSessionID(payload))
}

View File

@@ -10,6 +10,7 @@ import (
"time"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
func TestAntigravityBuildRequest_SanitizesGeminiToolSchema(t *testing.T) {
@@ -110,6 +111,86 @@ func TestAntigravityBuildRequest_UsesAuthProjectID(t *testing.T) {
}
}
func TestAntigravityBuildRequest_UsesRouteModelWhenPayloadContainsDifferentModel(t *testing.T) {
body := buildRequestBodyFromRawPayload(t, "gemini-3-flash-agent", []byte(`{
"model": "gemini-3.1-flash-lite",
"request": {
"contents": [
{
"role": "user",
"parts": [{"text": "Perform a web search"}]
}
],
"tools": [{"googleSearch": {}}]
}
}`))
if got, ok := body["model"].(string); !ok || got != "gemini-3-flash-agent" {
t.Fatalf("request model should stay on route model, got=%v", body["model"])
}
}
func TestAntigravityBuildRequest_PreservesIndependentWebSearchRequestType(t *testing.T) {
body := buildRequestBodyFromRawPayload(t, "gemini-3.1-flash-lite", []byte(`{
"requestType": "web_search",
"request": {
"contents": [
{
"role": "user",
"parts": [{"text": "北京天气 2026-06-12"}]
}
],
"tools": [
{
"googleSearch": {
"enhancedContent": {
"imageSearch": {
"maxResultCount": 5
}
}
}
}
],
"generationConfig": {
"candidateCount": 1
}
}
}`))
if got, ok := body["requestType"].(string); !ok || got != "web_search" {
t.Fatalf("requestType should stay web_search, got=%v", body["requestType"])
}
if _, ok := body["requestId"]; ok {
t.Fatalf("web_search request should not add requestId: %v", body["requestId"])
}
request, ok := body["request"].(map[string]any)
if !ok {
t.Fatalf("request missing or invalid: %v", body["request"])
}
if _, ok := request["sessionId"]; ok {
t.Fatalf("web_search request should not add request.sessionId: %v", request["sessionId"])
}
if got, ok := body["project"].(string); !ok || got != "project-1" {
t.Fatalf("project should come from auth metadata, got=%v", body["project"])
}
}
func TestShouldResolveAntigravityWebSearchGroundingURLsRequiresTypedWebSearchAndSearchRequest(t *testing.T) {
original := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}]}`)
translatedWithGoogleSearch := []byte(`{"requestType":"web_search","request":{"tools":[{"googleSearch":{}}]}}`)
translatedWithoutGoogleSearch := []byte(`{"request":{"contents":[]}}`)
if !shouldResolveAntigravityWebSearchGroundingURLs(sdktranslator.FormatClaude, original, translatedWithGoogleSearch) {
t.Fatal("expected typed Claude web search translated to web_search request to resolve grounding URLs")
}
if shouldResolveAntigravityWebSearchGroundingURLs(sdktranslator.FormatClaude, original, translatedWithoutGoogleSearch) {
t.Fatal("expected request without googleSearch to skip grounding URL resolution")
}
if shouldResolveAntigravityWebSearchGroundingURLs(sdktranslator.FormatOpenAI, original, translatedWithGoogleSearch) {
t.Fatal("expected non-Claude source format to skip grounding URL resolution")
}
}
func TestAntigravityPrepareRequestAuth_FetchesMissingProjectID(t *testing.T) {
executor := &AntigravityExecutor{}
auth := &cliproxyauth.Auth{Metadata: map[string]any{

View File

@@ -0,0 +1,104 @@
package helps
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func isAntigravityVertexSearchRedirect(rawURL string) bool {
parsed, err := url.Parse(rawURL)
if err != nil {
return false
}
return parsed.Scheme == "https" &&
parsed.Host == "vertexaisearch.cloud.google.com" &&
strings.HasPrefix(parsed.Path, "/grounding-api-redirect/")
}
func resolveAntigravityGroundingURL(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, rawURL string) string {
if !isAntigravityVertexSearchRedirect(rawURL) {
return rawURL
}
client := NewProxyAwareHTTPClient(ctx, cfg, auth, 0)
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
req, errReq := http.NewRequestWithContext(ctx, http.MethodHead, rawURL, nil)
if errReq != nil {
log.WithError(errReq).Debug("antigravity grounding url: create redirect request failed")
return rawURL
}
resp, errDo := client.Do(req)
if errDo != nil {
log.WithError(errDo).Debug("antigravity grounding url: resolve redirect failed")
return rawURL
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.WithError(errClose).Debug("antigravity grounding url: close redirect response failed")
}
}()
if resp.StatusCode < http.StatusMultipleChoices || resp.StatusCode >= http.StatusBadRequest {
return rawURL
}
location := strings.TrimSpace(resp.Header.Get("Location"))
if location == "" {
return rawURL
}
parsed, errParse := url.Parse(location)
if errParse != nil || parsed.Scheme != "https" || parsed.Host == "" {
return rawURL
}
return location
}
// ResolveAntigravityGroundingURLs replaces Vertex Search redirect URLs in grounding chunks with their target URLs.
func ResolveAntigravityGroundingURLs(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte) []byte {
if len(payload) == 0 {
return payload
}
basePath := "response.candidates.0.groundingMetadata.groundingChunks"
chunks := gjson.GetBytes(payload, basePath)
if !chunks.IsArray() {
basePath = "candidates.0.groundingMetadata.groundingChunks"
chunks = gjson.GetBytes(payload, basePath)
}
if !chunks.IsArray() {
return payload
}
output := payload
resolved := map[string]string{}
for i, chunk := range chunks.Array() {
uri := strings.TrimSpace(chunk.Get("web.uri").String())
if uri == "" {
continue
}
resolvedURI, ok := resolved[uri]
if !ok {
resolvedURI = resolveAntigravityGroundingURL(ctx, cfg, auth, uri)
resolved[uri] = resolvedURI
}
if resolvedURI == uri {
continue
}
updated, errSet := sjson.SetBytes(output, fmt.Sprintf("%s.%d.web.uri", basePath, i), resolvedURI)
if errSet != nil {
log.WithError(errSet).Debug("antigravity grounding url: set resolved url failed")
continue
}
output = updated
}
return output
}

View File

@@ -0,0 +1,66 @@
package helps
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/tidwall/gjson"
)
type groundingURLRoundTripper func(*http.Request) (*http.Response, error)
func (f groundingURLRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestResolveAntigravityGroundingURLsResolvesVertexRedirects(t *testing.T) {
t.Parallel()
const redirectURL = "https://vertexaisearch.cloud.google.com/grounding-api-redirect/example-token"
const resolvedURL = "https://example.com/weather"
var sawRedirectRequest bool
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", groundingURLRoundTripper(func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodHead {
t.Fatalf("method = %s, want HEAD", req.Method)
}
if req.URL.String() != redirectURL {
t.Fatalf("url = %s, want %s", req.URL.String(), redirectURL)
}
sawRedirectRequest = true
return &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{
"Location": []string{resolvedURL},
},
Body: io.NopCloser(strings.NewReader("")),
}, nil
}))
input := []byte(`{
"response": {
"candidates": [{
"groundingMetadata": {
"groundingChunks": [
{"web": {"uri": "` + redirectURL + `", "title": "Weather"}},
{"web": {"uri": "https://already.example/source", "title": "Existing"}}
]
}
}]
}
}`)
output := ResolveAntigravityGroundingURLs(ctx, nil, nil, input)
if !sawRedirectRequest {
t.Fatal("expected resolver to request the vertex redirect")
}
if got := gjson.GetBytes(output, "response.candidates.0.groundingMetadata.groundingChunks.0.web.uri").String(); got != resolvedURL {
t.Fatalf("resolved uri = %q, want %q; output=%s", got, resolvedURL, output)
}
if got := gjson.GetBytes(output, "response.candidates.0.groundingMetadata.groundingChunks.1.web.uri").String(); got != "https://already.example/source" {
t.Fatalf("non-vertex uri = %q", got)
}
}