mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-07 00:24:17 +08:00
gjson.GetBytes copies the matched subtree before returning it, so every read of request.contents on a multi-megabyte request allocated another copy of it. The reasoning replay, executor, signature validation and session identity paths all read whole arrays this way, and hasExplicitSession parsed the entire body with gjson.ParseBytes on every request. Use the no-copy helpers for these reads. Each call site only reads from the result and writes through sjson, which allocates a new buffer, so the payload stays immutable while the results are alive.
438 lines
15 KiB
Go
438 lines
15 KiB
Go
// Package executor provides runtime execution capabilities for various AI service providers.
|
|
// This file implements the Antigravity executor that proxies requests to the antigravity
|
|
// upstream using OAuth credentials.
|
|
package executor
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
|
|
internalsignature "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
|
antigravityclaude "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/claude"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
const (
|
|
antigravityBaseURLDaily = "https://daily-cloudcode-pa.googleapis.com"
|
|
antigravitySandboxBaseURLDaily = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
|
antigravityBaseURLProd = "https://cloudcode-pa.googleapis.com"
|
|
antigravityCountTokensPath = "/v1internal:countTokens"
|
|
antigravityStreamPath = "/v1internal:streamGenerateContent"
|
|
antigravityGeneratePath = "/v1internal:generateContent"
|
|
antigravityClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
|
antigravityClientSecret = "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"
|
|
antigravityAuthType = "antigravity"
|
|
refreshSkew = 3000 * time.Second
|
|
antigravityCreditsHintRefreshInterval = 10 * time.Minute
|
|
antigravityCreditsHintRefreshTimeout = 5 * time.Second
|
|
antigravityShortQuotaCooldownThreshold = 5 * time.Minute
|
|
antigravityInstantRetryThreshold = 3 * time.Second
|
|
// systemInstruction = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**"
|
|
)
|
|
|
|
// AntigravityExecutor proxies requests to the antigravity upstream.
|
|
type AntigravityExecutor struct {
|
|
cfg *config.Config
|
|
}
|
|
|
|
// NewAntigravityExecutor creates a new Antigravity executor instance.
|
|
//
|
|
// Parameters:
|
|
// - cfg: The application configuration
|
|
//
|
|
// Returns:
|
|
// - *AntigravityExecutor: A new Antigravity executor instance
|
|
func NewAntigravityExecutor(cfg *config.Config) *AntigravityExecutor {
|
|
return &AntigravityExecutor{cfg: cfg}
|
|
}
|
|
|
|
func (e *AntigravityExecutor) obfuscateSensitiveWords(payload []byte) []byte {
|
|
if e == nil || e.cfg == nil || len(e.cfg.Antigravity.SensitiveWords) == 0 {
|
|
return payload
|
|
}
|
|
matcher := helps.BuildSensitiveWordMatcher(e.cfg.Antigravity.SensitiveWords)
|
|
return helps.ObfuscateSensitiveWordsInSystemInstruction(payload, matcher)
|
|
}
|
|
|
|
// antigravityTransport is a singleton HTTP/1.1 transport shared by all Antigravity requests.
|
|
// It is initialized once via antigravityTransportOnce to avoid leaking a new connection pool
|
|
// (and the goroutines managing it) on every request.
|
|
var (
|
|
antigravityTransport *http.Transport
|
|
antigravityTransportOnce sync.Once
|
|
)
|
|
|
|
func cloneTransportWithHTTP11(base *http.Transport) *http.Transport {
|
|
if base == nil {
|
|
return nil
|
|
}
|
|
|
|
clone := base.Clone()
|
|
clone.ForceAttemptHTTP2 = false
|
|
// Wipe TLSNextProto to prevent implicit HTTP/2 upgrade.
|
|
clone.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)
|
|
if clone.TLSClientConfig == nil {
|
|
clone.TLSClientConfig = &tls.Config{}
|
|
} else {
|
|
clone.TLSClientConfig = clone.TLSClientConfig.Clone()
|
|
}
|
|
// Actively advertise only HTTP/1.1 in the ALPN handshake.
|
|
clone.TLSClientConfig.NextProtos = []string{"http/1.1"}
|
|
return clone
|
|
}
|
|
|
|
// initAntigravityTransport creates the shared HTTP/1.1 transport exactly once.
|
|
func initAntigravityTransport() {
|
|
base, ok := http.DefaultTransport.(*http.Transport)
|
|
if !ok {
|
|
base = &http.Transport{}
|
|
}
|
|
antigravityTransport = cloneTransportWithHTTP11(base)
|
|
}
|
|
|
|
// newAntigravityHTTPClient creates an HTTP client specifically for Antigravity,
|
|
// enforcing HTTP/1.1 by disabling HTTP/2 to perfectly mimic Node.js https defaults.
|
|
// The underlying Transport is a singleton to avoid leaking connection pools.
|
|
func newAntigravityHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
|
|
antigravityTransportOnce.Do(initAntigravityTransport)
|
|
|
|
client := helps.NewProxyAwareHTTPClient(ctx, cfg, auth, timeout)
|
|
// If no transport is set, use the shared HTTP/1.1 transport.
|
|
if client.Transport == nil {
|
|
client.Transport = antigravityTransport
|
|
return client
|
|
}
|
|
|
|
// Preserve proxy settings from proxy-aware transports while forcing HTTP/1.1.
|
|
if transport, ok := client.Transport.(*http.Transport); ok {
|
|
client.Transport = cloneTransportWithHTTP11(transport)
|
|
}
|
|
return client
|
|
}
|
|
|
|
func sanitizeAntigravityGeminiRequestSignatures(modelName string, rawJSON []byte) []byte {
|
|
if !antigravityUsesReasoningReplayCache(modelName) {
|
|
return rawJSON
|
|
}
|
|
rawJSON = internalsignature.SanitizeGeminiRequestThoughtSignatures(rawJSON, "request.contents")
|
|
return normalizeAntigravityGeminiFunctionResponseRoles(rawJSON)
|
|
}
|
|
|
|
func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
|
|
rawJSON = repairAntigravityGeminiFunctionResponseNames(rawJSON)
|
|
contents := util.GetGJSONBytesNoCopy(rawJSON, "request.contents")
|
|
if !contents.IsArray() {
|
|
return rawJSON
|
|
}
|
|
type functionRef struct {
|
|
id string
|
|
name string
|
|
}
|
|
out := rawJSON
|
|
var pending []functionRef
|
|
for contentIndex, content := range contents.Array() {
|
|
parts := content.Get("parts")
|
|
if !parts.IsArray() || len(parts.Array()) == 0 {
|
|
pending = nil
|
|
continue
|
|
}
|
|
var calls, responses []functionRef
|
|
var responseParts []json.RawMessage
|
|
hasOtherPart := false
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
switch {
|
|
case part.Get("functionCall").Exists():
|
|
calls = append(calls, functionRef{id: part.Get("functionCall.id").String(), name: part.Get("functionCall.name").String()})
|
|
case part.Get("functionResponse").Exists():
|
|
responses = append(responses, functionRef{id: part.Get("functionResponse.id").String(), name: part.Get("functionResponse.name").String()})
|
|
responseParts = append(responseParts, json.RawMessage(part.Raw))
|
|
default:
|
|
hasOtherPart = true
|
|
}
|
|
return true
|
|
})
|
|
if len(calls) > 0 && len(responses) == 0 {
|
|
pending = calls
|
|
continue
|
|
}
|
|
if len(responses) == 0 {
|
|
if hasOtherPart {
|
|
pending = nil
|
|
}
|
|
continue
|
|
}
|
|
if hasOtherPart || len(calls) > 0 {
|
|
pending = nil
|
|
continue
|
|
}
|
|
|
|
if len(pending) == len(responses) {
|
|
ordered := make([]json.RawMessage, 0, len(responseParts))
|
|
used := make([]bool, len(responses))
|
|
for _, call := range pending {
|
|
matched := -1
|
|
for responseIndex, response := range responses {
|
|
if used[responseIndex] {
|
|
continue
|
|
}
|
|
if (call.id != "" && response.id == call.id) || (call.id == "" && call.name != "" && response.name == call.name) {
|
|
matched = responseIndex
|
|
break
|
|
}
|
|
}
|
|
if matched < 0 {
|
|
ordered = nil
|
|
break
|
|
}
|
|
used[matched] = true
|
|
ordered = append(ordered, responseParts[matched])
|
|
}
|
|
if len(ordered) == len(responseParts) {
|
|
if encoded, errMarshal := json.Marshal(ordered); errMarshal == nil {
|
|
if updated, errSet := sjson.SetRawBytes(out, fmt.Sprintf("request.contents.%d.parts", contentIndex), encoded); errSet == nil {
|
|
out = updated
|
|
}
|
|
}
|
|
}
|
|
}
|
|
pending = nil
|
|
if content.Get("role").String() != "model" {
|
|
if updated, errSet := sjson.SetBytes(out, fmt.Sprintf("request.contents.%d.role", contentIndex), "model"); errSet == nil {
|
|
out = updated
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func repairAntigravityGeminiFunctionResponseNames(rawJSON []byte) []byte {
|
|
contents := util.GetGJSONBytesNoCopy(rawJSON, "request.contents")
|
|
if !contents.IsArray() {
|
|
return rawJSON
|
|
}
|
|
callIDToName := make(map[string]string)
|
|
contents.ForEach(func(_, content gjson.Result) bool {
|
|
parts := content.Get("parts")
|
|
if !parts.IsArray() {
|
|
return true
|
|
}
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
fc := part.Get("functionCall")
|
|
if fc.Exists() {
|
|
id := strings.TrimSpace(fc.Get("id").String())
|
|
name := strings.TrimSpace(fc.Get("name").String())
|
|
if id != "" && name != "" && name != "unknown" {
|
|
callIDToName[id] = name
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return true
|
|
})
|
|
if len(callIDToName) == 0 {
|
|
return rawJSON
|
|
}
|
|
|
|
out := rawJSON
|
|
contents.ForEach(func(contentIdx, content gjson.Result) bool {
|
|
parts := content.Get("parts")
|
|
if !parts.IsArray() {
|
|
return true
|
|
}
|
|
parts.ForEach(func(partIdx, part gjson.Result) bool {
|
|
fr := part.Get("functionResponse")
|
|
if fr.Exists() {
|
|
id := strings.TrimSpace(fr.Get("id").String())
|
|
name := strings.TrimSpace(fr.Get("name").String())
|
|
if id != "" && (name == "" || name == "unknown") {
|
|
if realName, ok := callIDToName[id]; ok {
|
|
path := fmt.Sprintf("request.contents.%d.parts.%d.functionResponse.name", contentIdx.Int(), partIdx.Int())
|
|
if updated, errSet := sjson.SetBytes(out, path, realName); errSet == nil {
|
|
out = updated
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return true
|
|
})
|
|
return out
|
|
}
|
|
|
|
func validateAntigravityRequestSignatures(ctx context.Context, modelName string, from sdktranslator.Format, rawJSON []byte) ([]byte, error) {
|
|
if from.String() != "claude" {
|
|
return rawJSON, nil
|
|
}
|
|
before := countClaudeThinkingBlocks(rawJSON)
|
|
if antigravityUsesReasoningReplayCache(modelName) {
|
|
rawJSON = antigravityclaude.StripInvalidGeminiSignatureThinkingBlocks(rawJSON)
|
|
logAntigravitySignatureStrip(before, countClaudeThinkingBlocks(rawJSON), "provider_cleanup", "empty_or_non_gemini_signature")
|
|
return rawJSON, nil
|
|
}
|
|
// Claude models accept only Claude-format thinking signatures.
|
|
rawJSON = antigravityclaude.StripEmptySignatureThinkingBlocks(rawJSON)
|
|
logAntigravitySignatureStrip(before, countClaudeThinkingBlocks(rawJSON), "prefix_cleanup", "empty_or_non_claude_signature")
|
|
if cache.SignatureCacheEnabled() {
|
|
return rawJSON, nil
|
|
}
|
|
if !cache.SignatureBypassStrictMode() {
|
|
// Non-strict bypass: let the translator handle invalid signatures
|
|
// by dropping unsigned thinking blocks silently (no 400).
|
|
return rawJSON, nil
|
|
}
|
|
before = countClaudeThinkingBlocks(rawJSON)
|
|
rawJSON = antigravityclaude.StripInvalidBypassSignatureThinkingBlocks(rawJSON)
|
|
logAntigravitySignatureStrip(before, countClaudeThinkingBlocks(rawJSON), "strict_bypass", "invalid_antigravity_claude_signature")
|
|
return rawJSON, nil
|
|
}
|
|
|
|
func hasAntigravityClaudeTypedWebSearchTool(payload []byte) bool {
|
|
tools := util.GetGJSONBytesNoCopy(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 := util.GetGJSONBytesNoCopy(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 := util.GetGJSONBytesNoCopy(rawJSON, "messages")
|
|
if !messages.IsArray() {
|
|
return 0
|
|
}
|
|
|
|
count := 0
|
|
messages.ForEach(func(_, message gjson.Result) bool {
|
|
content := message.Get("content")
|
|
if !content.IsArray() {
|
|
return true
|
|
}
|
|
content.ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("type").String() == "thinking" {
|
|
count++
|
|
}
|
|
return true
|
|
})
|
|
return true
|
|
})
|
|
return count
|
|
}
|
|
|
|
func logAntigravitySignatureStrip(before, after int, stage, reason string) {
|
|
removed := before - after
|
|
if removed <= 0 {
|
|
return
|
|
}
|
|
log.WithFields(log.Fields{
|
|
"component": "signature_sanitizer",
|
|
"executor": "antigravity",
|
|
"target_provider": "claude",
|
|
"action": "drop_thinking_blocks",
|
|
"stage": stage,
|
|
"reason": reason,
|
|
"count": removed,
|
|
}).Debug("antigravity executor: dropped Claude thinking blocks with invalid signatures")
|
|
}
|
|
|
|
// Identifier returns the executor identifier.
|
|
func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType }
|
|
|
|
// PrepareRequest injects Antigravity credentials into the outgoing HTTP request.
|
|
func (e *AntigravityExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
token, _, errToken := e.ensureAccessToken(req.Context(), auth)
|
|
if errToken != nil {
|
|
return errToken
|
|
}
|
|
if strings.TrimSpace(token) == "" {
|
|
return statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
return nil
|
|
}
|
|
|
|
// HttpRequest injects Antigravity credentials into the request and executes it.
|
|
// It uses a whitelist approach: all incoming headers are stripped and only
|
|
// the minimum set required by the Antigravity protocol is explicitly set.
|
|
func (e *AntigravityExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
|
if req == nil {
|
|
return nil, fmt.Errorf("antigravity executor: request is nil")
|
|
}
|
|
if ctx == nil {
|
|
ctx = req.Context()
|
|
}
|
|
httpReq := req.WithContext(ctx)
|
|
|
|
// --- Whitelist: save only the headers we need from the original request ---
|
|
contentType := httpReq.Header.Get("Content-Type")
|
|
|
|
// Wipe ALL incoming headers
|
|
for k := range httpReq.Header {
|
|
delete(httpReq.Header, k)
|
|
}
|
|
|
|
// --- Set only the headers Antigravity actually sends ---
|
|
if contentType != "" {
|
|
httpReq.Header.Set("Content-Type", contentType)
|
|
}
|
|
// Content-Length is managed automatically by Go's http.Client from the Body
|
|
httpReq.Header.Set("User-Agent", resolveUserAgent(auth))
|
|
httpReq.Close = true // sends Connection: close
|
|
|
|
// Inject Authorization: Bearer <token>
|
|
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
|
|
return httpClient.Do(httpReq)
|
|
}
|