mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-20 03:17:20 +08:00
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:
@@ -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, ¶m)
|
||||
@@ -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, ¶m)
|
||||
@@ -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), ¶m)
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
104
internal/runtime/executor/helps/antigravity_grounding_urls.go
Normal file
104
internal/runtime/executor/helps/antigravity_grounding_urls.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user