mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-21 23:52:50 +08:00
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.
427 lines
12 KiB
Go
427 lines
12 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"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) {
|
|
body := buildRequestBodyFromPayload(t, "gemini-2.5-pro")
|
|
|
|
decl := extractFirstFunctionDeclaration(t, body)
|
|
if _, ok := decl["parametersJsonSchema"]; ok {
|
|
t.Fatalf("parametersJsonSchema should be renamed to parameters")
|
|
}
|
|
|
|
params, ok := decl["parameters"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("parameters missing or invalid type")
|
|
}
|
|
assertSchemaSanitizedAndPropertyPreserved(t, params)
|
|
}
|
|
|
|
func TestAntigravityBuildRequest_SanitizesAntigravityToolSchema(t *testing.T) {
|
|
body := buildRequestBodyFromPayload(t, "claude-opus-4-6")
|
|
|
|
decl := extractFirstFunctionDeclaration(t, body)
|
|
params, ok := decl["parameters"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("parameters missing or invalid type")
|
|
}
|
|
assertSchemaSanitizedAndPropertyPreserved(t, params)
|
|
}
|
|
|
|
func TestAntigravityBuildRequest_SkipsSchemaSanitizationWithoutToolsField(t *testing.T) {
|
|
body := buildRequestBodyFromRawPayload(t, "gemini-3.1-flash-image", []byte(`{
|
|
"request": {
|
|
"contents": [
|
|
{
|
|
"role": "user",
|
|
"x-debug": "keep-me",
|
|
"parts": [
|
|
{
|
|
"text": "hello"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"nonSchema": {
|
|
"nullable": true,
|
|
"x-extra": "keep-me"
|
|
},
|
|
"generationConfig": {
|
|
"maxOutputTokens": 128
|
|
}
|
|
}
|
|
}`))
|
|
|
|
assertNonSchemaRequestPreserved(t, body)
|
|
}
|
|
|
|
func TestAntigravityBuildRequest_SkipsSchemaSanitizationWithEmptyToolsArray(t *testing.T) {
|
|
body := buildRequestBodyFromRawPayload(t, "gemini-3.1-flash-image", []byte(`{
|
|
"request": {
|
|
"tools": [],
|
|
"contents": [
|
|
{
|
|
"role": "user",
|
|
"x-debug": "keep-me",
|
|
"parts": [
|
|
{
|
|
"text": "hello"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"nonSchema": {
|
|
"nullable": true,
|
|
"x-extra": "keep-me"
|
|
},
|
|
"generationConfig": {
|
|
"maxOutputTokens": 128
|
|
}
|
|
}
|
|
}`))
|
|
|
|
assertNonSchemaRequestPreserved(t, body)
|
|
}
|
|
|
|
func TestAntigravityBuildRequest_UsesAuthProjectID(t *testing.T) {
|
|
body := buildRequestBodyFromRawPayload(t, "gemini-3.1-pro", []byte(`{
|
|
"request": {
|
|
"contents": [
|
|
{
|
|
"role": "user",
|
|
"parts": [{"text": "hello"}]
|
|
}
|
|
]
|
|
}
|
|
}`))
|
|
|
|
if got, ok := body["project"].(string); !ok || got != "project-1" {
|
|
t.Fatalf("project should come from auth metadata, got=%v", body["project"])
|
|
}
|
|
}
|
|
|
|
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{
|
|
"access_token": "token",
|
|
"expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
|
|
}}
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.String() != "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" {
|
|
t.Fatalf("unexpected project discovery request: %s", req.URL.String())
|
|
}
|
|
if got := req.Header.Get("X-Goog-Api-Client"); got != "" {
|
|
t.Fatalf("X-Goog-Api-Client = %q, want empty", got)
|
|
}
|
|
raw, errRead := io.ReadAll(req.Body)
|
|
if errRead != nil {
|
|
t.Fatalf("read discovery body: %v", errRead)
|
|
}
|
|
if !strings.Contains(string(raw), `"ideType":"ANTIGRAVITY"`) {
|
|
t.Fatalf("unexpected discovery body: %s", string(raw))
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"cloudaicompanionProject":"fetched-project"}`)),
|
|
}, nil
|
|
}))
|
|
|
|
updated, err := executor.PrepareRequestAuth(ctx, auth)
|
|
if err != nil {
|
|
t.Fatalf("PrepareRequestAuth error: %v", err)
|
|
}
|
|
if updated == nil {
|
|
t.Fatalf("PrepareRequestAuth returned nil auth")
|
|
}
|
|
if _, ok := auth.Metadata["project_id"]; ok {
|
|
t.Fatalf("original auth metadata should not be mutated")
|
|
}
|
|
if got, ok := updated.Metadata["project_id"].(string); !ok || got != "fetched-project" {
|
|
t.Fatalf("updated auth metadata project_id = %v, want fetched-project", updated.Metadata["project_id"])
|
|
}
|
|
}
|
|
|
|
func TestAntigravityBuildRequest_RejectsMissingProjectID(t *testing.T) {
|
|
executor := &AntigravityExecutor{}
|
|
auth := &cliproxyauth.Auth{Metadata: map[string]any{}}
|
|
|
|
_, err := executor.buildRequest(context.Background(), auth, "token", "gemini-3.1-pro", []byte(`{"request":{}}`), false, "", "https://example.com")
|
|
if err == nil {
|
|
t.Fatalf("buildRequest should fail when auth has no project_id")
|
|
}
|
|
status, ok := err.(interface{ StatusCode() int })
|
|
if !ok {
|
|
t.Fatalf("error should expose status code, got %T", err)
|
|
}
|
|
if got := status.StatusCode(); got != http.StatusBadRequest {
|
|
t.Fatalf("status code = %d, want %d", got, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func assertNonSchemaRequestPreserved(t *testing.T, body map[string]any) {
|
|
t.Helper()
|
|
|
|
request, ok := body["request"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("request missing or invalid type")
|
|
}
|
|
|
|
contents, ok := request["contents"].([]any)
|
|
if !ok || len(contents) == 0 {
|
|
t.Fatalf("contents missing or empty")
|
|
}
|
|
content, ok := contents[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("content missing or invalid type")
|
|
}
|
|
if got, ok := content["x-debug"].(string); !ok || got != "keep-me" {
|
|
t.Fatalf("x-debug should be preserved when no tool schema exists, got=%v", content["x-debug"])
|
|
}
|
|
|
|
nonSchema, ok := request["nonSchema"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("nonSchema missing or invalid type")
|
|
}
|
|
if _, ok := nonSchema["nullable"]; !ok {
|
|
t.Fatalf("nullable should be preserved outside schema cleanup path")
|
|
}
|
|
if got, ok := nonSchema["x-extra"].(string); !ok || got != "keep-me" {
|
|
t.Fatalf("x-extra should be preserved outside schema cleanup path, got=%v", nonSchema["x-extra"])
|
|
}
|
|
|
|
if generationConfig, ok := request["generationConfig"].(map[string]any); ok {
|
|
if _, ok := generationConfig["maxOutputTokens"]; ok {
|
|
t.Fatalf("maxOutputTokens should still be removed for non-Claude requests")
|
|
}
|
|
}
|
|
}
|
|
|
|
func buildRequestBodyFromPayload(t *testing.T, modelName string) map[string]any {
|
|
t.Helper()
|
|
return buildRequestBodyFromRawPayload(t, modelName, []byte(`{
|
|
"request": {
|
|
"tools": [
|
|
{
|
|
"function_declarations": [
|
|
{
|
|
"name": "tool_1",
|
|
"parametersJsonSchema": {
|
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
"$id": "root-schema",
|
|
"type": "object",
|
|
"properties": {
|
|
"$id": {"type": "string"},
|
|
"arg": {
|
|
"type": "object",
|
|
"prefill": "hello",
|
|
"properties": {
|
|
"mode": {
|
|
"type": "string",
|
|
"deprecated": true,
|
|
"enum": ["a", "b"],
|
|
"enumTitles": ["A", "B"]
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"patternProperties": {
|
|
"^x-": {"type": "string"}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}`))
|
|
}
|
|
|
|
func buildRequestBodyFromRawPayload(t *testing.T, modelName string, payload []byte) map[string]any {
|
|
t.Helper()
|
|
|
|
executor := &AntigravityExecutor{}
|
|
auth := &cliproxyauth.Auth{Metadata: map[string]any{"project_id": "project-1"}}
|
|
|
|
req, err := executor.buildRequest(context.Background(), auth, "token", modelName, payload, false, "", "https://example.com")
|
|
if err != nil {
|
|
t.Fatalf("buildRequest error: %v", err)
|
|
}
|
|
|
|
return requestBody(t, req)
|
|
}
|
|
|
|
func requestBody(t *testing.T, req *http.Request) map[string]any {
|
|
t.Helper()
|
|
|
|
raw, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
t.Fatalf("read request body error: %v", err)
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
t.Fatalf("unmarshal request body error: %v, body=%s", err, string(raw))
|
|
}
|
|
return body
|
|
}
|
|
|
|
func extractFirstFunctionDeclaration(t *testing.T, body map[string]any) map[string]any {
|
|
t.Helper()
|
|
|
|
request, ok := body["request"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("request missing or invalid type")
|
|
}
|
|
tools, ok := request["tools"].([]any)
|
|
if !ok || len(tools) == 0 {
|
|
t.Fatalf("tools missing or empty")
|
|
}
|
|
tool, ok := tools[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("first tool invalid type")
|
|
}
|
|
decls, ok := tool["function_declarations"].([]any)
|
|
if !ok || len(decls) == 0 {
|
|
t.Fatalf("function_declarations missing or empty")
|
|
}
|
|
decl, ok := decls[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("first function declaration invalid type")
|
|
}
|
|
return decl
|
|
}
|
|
|
|
func assertSchemaSanitizedAndPropertyPreserved(t *testing.T, params map[string]any) {
|
|
t.Helper()
|
|
|
|
if _, ok := params["$id"]; ok {
|
|
t.Fatalf("root $id should be removed from schema")
|
|
}
|
|
if _, ok := params["patternProperties"]; ok {
|
|
t.Fatalf("patternProperties should be removed from schema")
|
|
}
|
|
|
|
props, ok := params["properties"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("properties missing or invalid type")
|
|
}
|
|
if _, ok := props["$id"]; !ok {
|
|
t.Fatalf("property named $id should be preserved")
|
|
}
|
|
|
|
arg, ok := props["arg"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("arg property missing or invalid type")
|
|
}
|
|
if _, ok := arg["prefill"]; ok {
|
|
t.Fatalf("prefill should be removed from nested schema")
|
|
}
|
|
|
|
argProps, ok := arg["properties"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("arg.properties missing or invalid type")
|
|
}
|
|
mode, ok := argProps["mode"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("mode property missing or invalid type")
|
|
}
|
|
if _, ok := mode["enumTitles"]; ok {
|
|
t.Fatalf("enumTitles should be removed from nested schema")
|
|
}
|
|
if _, ok := mode["deprecated"]; ok {
|
|
t.Fatalf("deprecated should be removed from nested schema")
|
|
}
|
|
}
|