From 2d84d2fb6ade34f852eabdb299ac72555cd2e789 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 21 Nov 2025 11:33:00 +0800 Subject: [PATCH] **feat(auth, executor, cmd): add Antigravity provider integration** - Implemented OAuth login flow for the Antigravity provider in `auth/antigravity.go`. - Added `AntigravityExecutor` for handling requests and streaming via Antigravity APIs. - Created `antigravity_login.go` command for triggering Antigravity authentication. - Introduced OpenAI-to-Antigravity translation logic in `translator/antigravity/openai/chat-completions`. **refactor(translator, executor): update Gemini CLI response translation and add Antigravity payload customization** - Renamed Gemini CLI translation methods to align with response handling (`ConvertGeminiCliResponseToGemini` and `ConvertGeminiCliResponseToGeminiNonStream`). - Updated `init.go` to reflect these method changes. - Introduced `geminiToAntigravity` function to embed metadata (`model`, `userAgent`, `project`, etc.) into Antigravity payloads. - Added random project, request, and session ID generators for enhanced tracking. - Streamlined `buildRequest` to use `geminiToAntigravity` transformation before request execution. --- cmd/server/main.go | 5 + internal/cmd/antigravity_login.go | 38 ++ internal/cmd/auth_manager.go | 1 + .../runtime/executor/antigravity_executor.go | 560 ++++++++++++++++++ ...quest.go => gemini-cli_gemini_response.go} | 13 +- internal/translator/gemini-cli/gemini/init.go | 4 +- sdk/auth/antigravity.go | 289 +++++++++ sdk/auth/refresh_registry.go | 1 + sdk/cliproxy/service.go | 6 + sdk/translator/formats.go | 1 + 10 files changed, 912 insertions(+), 6 deletions(-) create mode 100644 internal/cmd/antigravity_login.go create mode 100644 internal/runtime/executor/antigravity_executor.go rename internal/translator/gemini-cli/gemini/{gemini_gemini-cli_request.go => gemini-cli_gemini_response.go} (83%) create mode 100644 sdk/auth/antigravity.go diff --git a/cmd/server/main.go b/cmd/server/main.go index a583399f..bbf500e7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -61,6 +61,7 @@ func main() { var iflowLogin bool var iflowCookie bool var noBrowser bool + var antigravityLogin bool var projectID string var vertexImport string var configPath string @@ -74,6 +75,7 @@ func main() { flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth") flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie") flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth") + flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth") flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path") flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file") @@ -431,6 +433,9 @@ func main() { } else if login { // Handle Google/Gemini login cmd.DoLogin(cfg, projectID, options) + } else if antigravityLogin { + // Handle Antigravity login + cmd.DoAntigravityLogin(cfg, options) } else if codexLogin { // Handle Codex login cmd.DoCodexLogin(cfg, options) diff --git a/internal/cmd/antigravity_login.go b/internal/cmd/antigravity_login.go new file mode 100644 index 00000000..b2602638 --- /dev/null +++ b/internal/cmd/antigravity_login.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" + log "github.com/sirupsen/logrus" +) + +// DoAntigravityLogin triggers the OAuth flow for the antigravity provider and saves tokens. +func DoAntigravityLogin(cfg *config.Config, options *LoginOptions) { + if options == nil { + options = &LoginOptions{} + } + + manager := newAuthManager() + authOpts := &sdkAuth.LoginOptions{ + NoBrowser: options.NoBrowser, + Metadata: map[string]string{}, + Prompt: options.Prompt, + } + + record, savedPath, err := manager.Login(context.Background(), "antigravity", cfg, authOpts) + if err != nil { + log.Errorf("Antigravity authentication failed: %v", err) + return + } + + if savedPath != "" { + fmt.Printf("Authentication saved to %s\n", savedPath) + } + if record != nil && record.Label != "" { + fmt.Printf("Authenticated as %s\n", record.Label) + } + fmt.Println("Antigravity authentication successful!") +} diff --git a/internal/cmd/auth_manager.go b/internal/cmd/auth_manager.go index 6514c1cb..e6caa954 100644 --- a/internal/cmd/auth_manager.go +++ b/internal/cmd/auth_manager.go @@ -18,6 +18,7 @@ func newAuthManager() *sdkAuth.Manager { sdkAuth.NewClaudeAuthenticator(), sdkAuth.NewQwenAuthenticator(), sdkAuth.NewIFlowAuthenticator(), + sdkAuth.NewAntigravityAuthenticator(), ) return manager } diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go new file mode 100644 index 00000000..607d6aa2 --- /dev/null +++ b/internal/runtime/executor/antigravity_executor.go @@ -0,0 +1,560 @@ +package executor + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + antigravityBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com" + antigravityStreamPath = "/v1internal:streamGenerateContent" + antigravityGeneratePath = "/v1internal:generateContent" + antigravityModelsPath = "/v1internal:fetchAvailableModels" + antigravityClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" + antigravityClientSecret = "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" + defaultAntigravityAgent = "antigravity/1.11.3 windows/amd64" + antigravityAuthType = "antigravity" + refreshSkew = 5 * time.Minute + streamScannerBuffer int = 20_971_520 +) + +var randSource = rand.New(rand.NewSource(time.Now().UnixNano())) + +// AntigravityExecutor proxies requests to the antigravity upstream. +type AntigravityExecutor struct { + cfg *config.Config +} + +// NewAntigravityExecutor constructs a new executor instance. +func NewAntigravityExecutor(cfg *config.Config) *AntigravityExecutor { + return &AntigravityExecutor{cfg: cfg} +} + +// Identifier implements ProviderExecutor. +func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType } + +// PrepareRequest implements ProviderExecutor. +func (e *AntigravityExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil } + +// Execute handles non-streaming requests via the antigravity generate endpoint. +func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth) + if errToken != nil { + return resp, errToken + } + if updatedAuth != nil { + auth = updatedAuth + } + + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) + defer reporter.trackFailure(ctx, &err) + + from := opts.SourceFormat + to := sdktranslator.FromString("gemini-cli") + translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) + + httpReq, errReq := e.buildRequest(ctx, auth, token, req.Model, translated, false, opts.Alt) + if errReq != nil { + return resp, errReq + } + + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpResp, errDo := httpClient.Do(httpReq) + if errDo != nil { + recordAPIResponseError(ctx, e.cfg, errDo) + return resp, errDo + } + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("antigravity executor: close response body error: %v", errClose) + } + }() + + recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + bodyBytes, errRead := io.ReadAll(httpResp.Body) + if errRead != nil { + recordAPIResponseError(ctx, e.cfg, errRead) + return resp, errRead + } + appendAPIResponseChunk(ctx, e.cfg, bodyBytes) + + if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { + log.Debugf("antigravity executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), bodyBytes)) + err = statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)} + return resp, err + } + + var param any + converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, bodyBytes, ¶m) + resp = cliproxyexecutor.Response{Payload: []byte(converted)} + reporter.ensurePublished(ctx) + return resp, nil +} + +// ExecuteStream handles streaming requests via the antigravity upstream. +func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) { + ctx = context.WithValue(ctx, "alt", "") + + token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth) + if errToken != nil { + return nil, errToken + } + if updatedAuth != nil { + auth = updatedAuth + } + + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) + defer reporter.trackFailure(ctx, &err) + + from := opts.SourceFormat + to := sdktranslator.FromString("gemini-cli") + translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) + + httpReq, errReq := e.buildRequest(ctx, auth, token, req.Model, translated, true, opts.Alt) + if errReq != nil { + return nil, errReq + } + + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpResp, errDo := httpClient.Do(httpReq) + if errDo != nil { + recordAPIResponseError(ctx, e.cfg, errDo) + return nil, errDo + } + recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { + bodyBytes, _ := io.ReadAll(httpResp.Body) + appendAPIResponseChunk(ctx, e.cfg, bodyBytes) + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("antigravity executor: close response body error: %v", errClose) + } + err = statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)} + return nil, err + } + + out := make(chan cliproxyexecutor.StreamChunk) + stream = out + go func() { + defer close(out) + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("antigravity executor: close response body error: %v", errClose) + } + }() + scanner := bufio.NewScanner(httpResp.Body) + scanner.Buffer(nil, streamScannerBuffer) + var param any + for scanner.Scan() { + line := scanner.Bytes() + appendAPIResponseChunk(ctx, e.cfg, line) + chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, bytes.Clone(line), ¶m) + for i := range chunks { + out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])} + } + } + tail := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, []byte("[DONE]"), ¶m) + for i := range tail { + out <- cliproxyexecutor.StreamChunk{Payload: []byte(tail[i])} + } + if errScan := scanner.Err(); errScan != nil { + recordAPIResponseError(ctx, e.cfg, errScan) + reporter.publishFailure(ctx) + out <- cliproxyexecutor.StreamChunk{Err: errScan} + } else { + reporter.ensurePublished(ctx) + } + }() + return stream, nil +} + +// Refresh refreshes the OAuth token using the refresh token. +func (e *AntigravityExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if auth == nil { + return auth, nil + } + updated, errRefresh := e.refreshToken(ctx, auth.Clone()) + if errRefresh != nil { + return nil, errRefresh + } + return updated, nil +} + +// CountTokens is not supported for the antigravity provider. +func (e *AntigravityExecutor) CountTokens(context.Context, *cliproxyauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusNotImplemented, msg: "count tokens not supported"} +} + +// FetchAntigravityModels retrieves available models using the supplied auth. +func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo { + exec := &AntigravityExecutor{cfg: cfg} + token, updatedAuth, errToken := exec.ensureAccessToken(ctx, auth) + if errToken != nil || token == "" { + return nil + } + if updatedAuth != nil { + auth = updatedAuth + } + + modelsURL := buildBaseURL(auth) + antigravityModelsPath + httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, modelsURL, bytes.NewReader([]byte(`{}`))) + if errReq != nil { + return nil + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + httpReq.Header.Set("User-Agent", resolveUserAgent(auth)) + if host := resolveHost(auth); host != "" { + httpReq.Host = host + } + + httpClient := newProxyAwareHTTPClient(ctx, cfg, auth, 0) + httpResp, errDo := httpClient.Do(httpReq) + if errDo != nil { + return nil + } + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("antigravity executor: close response body error: %v", errClose) + } + }() + + bodyBytes, errRead := io.ReadAll(httpResp.Body) + if errRead != nil { + return nil + } + if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { + return nil + } + + result := gjson.GetBytes(bodyBytes, "models") + if !result.Exists() { + return nil + } + + now := time.Now().Unix() + models := make([]*registry.ModelInfo, 0, len(result.Map())) + for id := range result.Map() { + models = append(models, ®istry.ModelInfo{ + ID: id, + Object: "model", + Created: now, + OwnedBy: antigravityAuthType, + Type: antigravityAuthType, + }) + } + return models +} + +func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *cliproxyauth.Auth) (string, *cliproxyauth.Auth, error) { + if auth == nil { + return "", nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"} + } + accessToken := metaStringValue(auth.Metadata, "access_token") + expiry := tokenExpiry(auth.Metadata) + if accessToken != "" && expiry.After(time.Now().Add(refreshSkew)) { + return accessToken, nil, nil + } + updated, errRefresh := e.refreshToken(ctx, auth.Clone()) + if errRefresh != nil { + return "", nil, errRefresh + } + return metaStringValue(updated.Metadata, "access_token"), updated, nil +} + +func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if auth == nil { + return nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"} + } + refreshToken := metaStringValue(auth.Metadata, "refresh_token") + if refreshToken == "" { + return auth, statusErr{code: http.StatusUnauthorized, msg: "missing refresh token"} + } + + form := url.Values{} + form.Set("client_id", antigravityClientID) + form.Set("client_secret", antigravityClientSecret) + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", refreshToken) + + httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, "https://oauth2.googleapis.com/token", strings.NewReader(form.Encode())) + if errReq != nil { + return auth, errReq + } + httpReq.Header.Set("Host", "oauth2.googleapis.com") + httpReq.Header.Set("User-Agent", defaultAntigravityAgent) + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpResp, errDo := httpClient.Do(httpReq) + if errDo != nil { + return auth, errDo + } + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("antigravity executor: close response body error: %v", errClose) + } + }() + + bodyBytes, errRead := io.ReadAll(httpResp.Body) + if errRead != nil { + return auth, errRead + } + + if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { + return auth, statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)} + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + TokenType string `json:"token_type"` + } + if errUnmarshal := json.Unmarshal(bodyBytes, &tokenResp); errUnmarshal != nil { + return auth, errUnmarshal + } + + if auth.Metadata == nil { + auth.Metadata = make(map[string]any) + } + auth.Metadata["access_token"] = tokenResp.AccessToken + if tokenResp.RefreshToken != "" { + auth.Metadata["refresh_token"] = tokenResp.RefreshToken + } + auth.Metadata["expires_in"] = tokenResp.ExpiresIn + auth.Metadata["timestamp"] = time.Now().UnixMilli() + auth.Metadata["expired"] = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339) + auth.Metadata["type"] = antigravityAuthType + return auth, nil +} + +func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyauth.Auth, token, modelName string, payload []byte, stream bool, alt string) (*http.Request, error) { + if token == "" { + return nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"} + } + + base := buildBaseURL(auth) + path := antigravityGeneratePath + if stream { + path = antigravityStreamPath + } + var requestURL strings.Builder + requestURL.WriteString(base) + requestURL.WriteString(path) + if stream { + if alt != "" { + requestURL.WriteString("?$alt=") + requestURL.WriteString(url.QueryEscape(alt)) + } else { + requestURL.WriteString("?alt=sse") + } + } else if alt != "" { + requestURL.WriteString("?$alt=") + requestURL.WriteString(url.QueryEscape(alt)) + } + + payload = geminiToAntigravity(modelName, payload) + httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload)) + if errReq != nil { + return nil, errReq + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + httpReq.Header.Set("User-Agent", resolveUserAgent(auth)) + if stream { + httpReq.Header.Set("Accept", "text/event-stream") + } else { + httpReq.Header.Set("Accept", "application/json") + } + if host := resolveHost(auth); host != "" { + httpReq.Host = host + } + + var authID, authLabel, authType, authValue string + if auth != nil { + authID = auth.ID + authLabel = auth.Label + authType, authValue = auth.AccountInfo() + } + recordAPIRequest(ctx, e.cfg, upstreamRequestLog{ + URL: requestURL.String(), + Method: http.MethodPost, + Headers: httpReq.Header.Clone(), + Body: payload, + Provider: e.Identifier(), + AuthID: authID, + AuthLabel: authLabel, + AuthType: authType, + AuthValue: authValue, + }) + + return httpReq, nil +} + +func tokenExpiry(metadata map[string]any) time.Time { + if metadata == nil { + return time.Time{} + } + if expStr, ok := metadata["expired"].(string); ok { + expStr = strings.TrimSpace(expStr) + if expStr != "" { + if parsed, errParse := time.Parse(time.RFC3339, expStr); errParse == nil { + return parsed + } + } + } + expiresIn, hasExpires := int64Value(metadata["expires_in"]) + tsMs, hasTimestamp := int64Value(metadata["timestamp"]) + if hasExpires && hasTimestamp { + return time.Unix(0, tsMs*int64(time.Millisecond)).Add(time.Duration(expiresIn) * time.Second) + } + return time.Time{} +} + +func metaStringValue(metadata map[string]any, key string) string { + if metadata == nil { + return "" + } + if v, ok := metadata[key]; ok { + switch typed := v.(type) { + case string: + return strings.TrimSpace(typed) + case []byte: + return strings.TrimSpace(string(typed)) + } + } + return "" +} + +func int64Value(value any) (int64, bool) { + switch typed := value.(type) { + case int: + return int64(typed), true + case int64: + return typed, true + case float64: + return int64(typed), true + case json.Number: + if i, errParse := typed.Int64(); errParse == nil { + return i, true + } + case string: + if strings.TrimSpace(typed) == "" { + return 0, false + } + if i, errParse := strconv.ParseInt(strings.TrimSpace(typed), 10, 64); errParse == nil { + return i, true + } + } + return 0, false +} + +func buildBaseURL(auth *cliproxyauth.Auth) string { + if auth != nil { + if auth.Attributes != nil { + if v := strings.TrimSpace(auth.Attributes["base_url"]); v != "" { + return strings.TrimSuffix(v, "/") + } + } + if auth.Metadata != nil { + if v, ok := auth.Metadata["base_url"].(string); ok { + v = strings.TrimSpace(v) + if v != "" { + return strings.TrimSuffix(v, "/") + } + } + } + } + return antigravityBaseURL +} + +func resolveHost(auth *cliproxyauth.Auth) string { + base := buildBaseURL(auth) + parsed, errParse := url.Parse(base) + if errParse != nil { + return "" + } + if parsed.Host != "" { + return parsed.Host + } + return strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://") +} + +func resolveUserAgent(auth *cliproxyauth.Auth) string { + if auth != nil { + if auth.Attributes != nil { + if ua := strings.TrimSpace(auth.Attributes["user_agent"]); ua != "" { + return ua + } + } + if auth.Metadata != nil { + if ua, ok := auth.Metadata["user_agent"].(string); ok && strings.TrimSpace(ua) != "" { + return strings.TrimSpace(ua) + } + } + } + return defaultAntigravityAgent +} + +func geminiToAntigravity(modelName string, payload []byte) []byte { + template, _ := sjson.Set(string(payload), "model", modelName) + template, _ = sjson.Set(template, "userAgent", "antigravity") + template, _ = sjson.Set(template, "project", generateProjectID()) + template, _ = sjson.Set(template, "requestId", generateRequestID()) + template, _ = sjson.Set(template, "request.sessionId", generateSessionID()) + + template, _ = sjson.Delete(template, "request.safetySettings") + template, _ = sjson.Set(template, "request.toolConfig.functionCallingConfig.mode", "VALIDATED") + + gjson.Get(template, "request.contents").ForEach(func(key, content gjson.Result) bool { + if content.Get("role").String() == "model" { + content.Get("parts").ForEach(func(partKey, part gjson.Result) bool { + if part.Get("functionCall").Exists() { + template, _ = sjson.Set(template, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") + } + return true + }) + } + return true + }) + + return []byte(template) +} + +func generateRequestID() string { + return "agent-" + uuid.NewString() +} + +func generateSessionID() string { + n := randSource.Int63n(9_000_000_000_000_000_000) + return "-" + strconv.FormatInt(n, 10) +} + +func generateProjectID() string { + adjectives := []string{"useful", "bright", "swift", "calm", "bold"} + nouns := []string{"fuze", "wave", "spark", "flow", "core"} + adj := adjectives[randSource.Intn(len(adjectives))] + noun := nouns[randSource.Intn(len(nouns))] + randomPart := strings.ToLower(uuid.NewString())[:5] + return adj + "-" + noun + "-" + randomPart +} diff --git a/internal/translator/gemini-cli/gemini/gemini_gemini-cli_request.go b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_response.go similarity index 83% rename from internal/translator/gemini-cli/gemini/gemini_gemini-cli_request.go rename to internal/translator/gemini-cli/gemini/gemini-cli_gemini_response.go index fc90105b..0ae931f1 100644 --- a/internal/translator/gemini-cli/gemini/gemini_gemini-cli_request.go +++ b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_response.go @@ -6,6 +6,7 @@ package gemini import ( + "bytes" "context" "fmt" @@ -13,7 +14,7 @@ import ( "github.com/tidwall/sjson" ) -// ConvertGeminiCliRequestToGemini parses and transforms a Gemini CLI API request into Gemini API format. +// ConvertGeminiCliResponseToGemini parses and transforms a Gemini CLI API request into Gemini API format. // It extracts the model name, system instruction, message contents, and tool declarations // from the raw JSON request and returns them in the format expected by the Gemini API. // The function performs the following transformations: @@ -29,7 +30,11 @@ import ( // // Returns: // - []string: The transformed request data in Gemini API format -func ConvertGeminiCliRequestToGemini(ctx context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []string { +func ConvertGeminiCliResponseToGemini(ctx context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []string { + if bytes.HasPrefix(rawJSON, []byte("data:")) { + rawJSON = bytes.TrimSpace(rawJSON[5:]) + } + if alt, ok := ctx.Value("alt").(string); ok { var chunk []byte if alt == "" { @@ -56,7 +61,7 @@ func ConvertGeminiCliRequestToGemini(ctx context.Context, _ string, originalRequ return []string{} } -// ConvertGeminiCliRequestToGeminiNonStream converts a non-streaming Gemini CLI request to a non-streaming Gemini response. +// ConvertGeminiCliResponseToGeminiNonStream converts a non-streaming Gemini CLI request to a non-streaming Gemini response. // This function processes the complete Gemini CLI request and transforms it into a single Gemini-compatible // JSON response. It extracts the response data from the request and returns it in the expected format. // @@ -68,7 +73,7 @@ func ConvertGeminiCliRequestToGemini(ctx context.Context, _ string, originalRequ // // Returns: // - string: A Gemini-compatible JSON response containing the response data -func ConvertGeminiCliRequestToGeminiNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string { +func ConvertGeminiCliResponseToGeminiNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string { responseResult := gjson.GetBytes(rawJSON, "response") if responseResult.Exists() { return responseResult.Raw diff --git a/internal/translator/gemini-cli/gemini/init.go b/internal/translator/gemini-cli/gemini/init.go index 934edddb..fbad4ab5 100644 --- a/internal/translator/gemini-cli/gemini/init.go +++ b/internal/translator/gemini-cli/gemini/init.go @@ -12,8 +12,8 @@ func init() { GeminiCLI, ConvertGeminiRequestToGeminiCLI, interfaces.TranslateResponse{ - Stream: ConvertGeminiCliRequestToGemini, - NonStream: ConvertGeminiCliRequestToGeminiNonStream, + Stream: ConvertGeminiCliResponseToGemini, + NonStream: ConvertGeminiCliResponseToGeminiNonStream, TokenCount: GeminiTokenCount, }, ) diff --git a/sdk/auth/antigravity.go b/sdk/auth/antigravity.go new file mode 100644 index 00000000..392cc227 --- /dev/null +++ b/sdk/auth/antigravity.go @@ -0,0 +1,289 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + log "github.com/sirupsen/logrus" +) + +const ( + antigravityClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" + antigravityClientSecret = "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" +) + +var antigravityScopes = []string{ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +} + +// AntigravityAuthenticator implements OAuth login for the antigravity provider. +type AntigravityAuthenticator struct{} + +// NewAntigravityAuthenticator constructs a new authenticator instance. +func NewAntigravityAuthenticator() Authenticator { return &AntigravityAuthenticator{} } + +// Provider returns the provider key for antigravity. +func (AntigravityAuthenticator) Provider() string { return "antigravity" } + +// RefreshLead instructs the manager to refresh five minutes before expiry. +func (AntigravityAuthenticator) RefreshLead() *time.Duration { + lead := 5 * time.Minute + return &lead +} + +// Login launches a local OAuth flow to obtain antigravity tokens and persists them. +func (AntigravityAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { + if cfg == nil { + return nil, fmt.Errorf("cliproxy auth: configuration is required") + } + if ctx == nil { + ctx = context.Background() + } + if opts == nil { + opts = &LoginOptions{} + } + + state, err := misc.GenerateRandomState() + if err != nil { + return nil, fmt.Errorf("antigravity: failed to generate state: %w", err) + } + + srv, port, cbChan, errServer := startAntigravityCallbackServer() + if errServer != nil { + return nil, fmt.Errorf("antigravity: failed to start callback server: %w", errServer) + } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + redirectURI := fmt.Sprintf("http://localhost:%d/oauth-callback", port) + authURL := buildAntigravityAuthURL(redirectURI, state) + + if !opts.NoBrowser { + fmt.Println("Opening browser for antigravity authentication") + if !browser.IsAvailable() { + log.Warn("No browser available; please open the URL manually") + util.PrintSSHTunnelInstructions(port) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } else if errOpen := browser.OpenURL(authURL); errOpen != nil { + log.Warnf("Failed to open browser automatically: %v", errOpen) + util.PrintSSHTunnelInstructions(port) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } + } else { + util.PrintSSHTunnelInstructions(port) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } + + fmt.Println("Waiting for antigravity authentication callback...") + + var cbRes callbackResult + select { + case res := <-cbChan: + cbRes = res + case <-time.After(5 * time.Minute): + return nil, fmt.Errorf("antigravity: authentication timed out") + } + + if cbRes.Error != "" { + return nil, fmt.Errorf("antigravity: authentication failed: %s", cbRes.Error) + } + if cbRes.State != state { + return nil, fmt.Errorf("antigravity: invalid state") + } + if cbRes.Code == "" { + return nil, fmt.Errorf("antigravity: missing authorization code") + } + + tokenResp, errToken := exchangeAntigravityCode(ctx, cbRes.Code, redirectURI) + if errToken != nil { + return nil, fmt.Errorf("antigravity: token exchange failed: %w", errToken) + } + + email := "" + if tokenResp.AccessToken != "" { + if info, errInfo := fetchAntigravityUserInfo(ctx, tokenResp.AccessToken); errInfo == nil && strings.TrimSpace(info.Email) != "" { + email = strings.TrimSpace(info.Email) + } + } + + now := time.Now() + metadata := map[string]any{ + "type": "antigravity", + "access_token": tokenResp.AccessToken, + "refresh_token": tokenResp.RefreshToken, + "expires_in": tokenResp.ExpiresIn, + "timestamp": now.UnixMilli(), + "expired": now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339), + } + if email != "" { + metadata["email"] = email + } + + fileName := sanitizeAntigravityFileName(email) + label := email + if label == "" { + label = "antigravity" + } + + fmt.Println("Antigravity authentication successful") + return &coreauth.Auth{ + ID: fileName, + Provider: "antigravity", + FileName: fileName, + Label: label, + Metadata: metadata, + }, nil +} + +type callbackResult struct { + Code string + Error string + State string +} + +func startAntigravityCallbackServer() (*http.Server, int, <-chan callbackResult, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, 0, nil, err + } + port := listener.Addr().(*net.TCPAddr).Port + resultCh := make(chan callbackResult, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/oauth-callback", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + res := callbackResult{ + Code: strings.TrimSpace(q.Get("code")), + Error: strings.TrimSpace(q.Get("error")), + State: strings.TrimSpace(q.Get("state")), + } + resultCh <- res + if res.Code != "" && res.Error == "" { + _, _ = w.Write([]byte("

Login successful

You can close this window.

")) + } else { + _, _ = w.Write([]byte("

Login failed

Please check the CLI output.

")) + } + }) + + srv := &http.Server{Handler: mux} + go func() { + if errServe := srv.Serve(listener); errServe != nil && !strings.Contains(errServe.Error(), "Server closed") { + log.Warnf("antigravity callback server error: %v", errServe) + } + }() + + return srv, port, resultCh, nil +} + +type antigravityTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + TokenType string `json:"token_type"` +} + +func exchangeAntigravityCode(ctx context.Context, code, redirectURI string) (*antigravityTokenResponse, error) { + data := url.Values{} + data.Set("code", code) + data.Set("client_id", antigravityClientID) + data.Set("client_secret", antigravityClientSecret) + data.Set("redirect_uri", redirectURI) + data.Set("grant_type", "authorization_code") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://oauth2.googleapis.com/token", strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, errDo := http.DefaultClient.Do(req) + if errDo != nil { + return nil, errDo + } + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + log.Errorf("antigravity token exchange: close body error: %v", errClose) + } + }() + + var token antigravityTokenResponse + if errDecode := json.NewDecoder(resp.Body).Decode(&token); errDecode != nil { + return nil, errDecode + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("oauth token exchange failed: status %d", resp.StatusCode) + } + return &token, nil +} + +type antigravityUserInfo struct { + Email string `json:"email"` +} + +func fetchAntigravityUserInfo(ctx context.Context, accessToken string) (*antigravityUserInfo, error) { + if strings.TrimSpace(accessToken) == "" { + return &antigravityUserInfo{}, nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, errDo := http.DefaultClient.Do(req) + if errDo != nil { + return nil, errDo + } + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + log.Errorf("antigravity userinfo: close body error: %v", errClose) + } + }() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return &antigravityUserInfo{}, nil + } + var info antigravityUserInfo + if errDecode := json.NewDecoder(resp.Body).Decode(&info); errDecode != nil { + return nil, errDecode + } + return &info, nil +} + +func buildAntigravityAuthURL(redirectURI, state string) string { + params := url.Values{} + params.Set("access_type", "offline") + params.Set("client_id", antigravityClientID) + params.Set("prompt", "consent") + params.Set("redirect_uri", redirectURI) + params.Set("response_type", "code") + params.Set("scope", strings.Join(antigravityScopes, " ")) + params.Set("state", state) + return "https://accounts.google.com/o/oauth2/v2/auth?" + params.Encode() +} + +func sanitizeAntigravityFileName(email string) string { + if strings.TrimSpace(email) == "" { + return "antigravity.json" + } + replacer := strings.NewReplacer("@", "_", ".", "_") + return fmt.Sprintf("antigravity-%s.json", replacer.Replace(email)) +} diff --git a/sdk/auth/refresh_registry.go b/sdk/auth/refresh_registry.go index a2a35764..e82ac684 100644 --- a/sdk/auth/refresh_registry.go +++ b/sdk/auth/refresh_registry.go @@ -13,6 +13,7 @@ func init() { registerRefreshLead("iflow", func() Authenticator { return NewIFlowAuthenticator() }) registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() }) registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() }) + registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() }) } func registerRefreshLead(provider string, factory func() Authenticator) { diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 04982cfe..5be25799 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -333,6 +333,8 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { s.coreManager.RegisterExecutor(executor.NewAIStudioExecutor(s.cfg, a.ID, s.wsGateway)) } return + case "antigravity": + s.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(s.cfg)) case "claude": s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg)) case "codex": @@ -634,6 +636,10 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { models = registry.GetGeminiCLIModels() case "aistudio": models = registry.GetAIStudioModels() + case "antigravity": + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + models = executor.FetchAntigravityModels(ctx, a, s.cfg) + cancel() case "claude": models = registry.GetClaudeModels() if entry := s.resolveConfigClaudeKey(a); entry != nil && len(entry.Models) > 0 { diff --git a/sdk/translator/formats.go b/sdk/translator/formats.go index 658eafae..aafe9e05 100644 --- a/sdk/translator/formats.go +++ b/sdk/translator/formats.go @@ -8,4 +8,5 @@ const ( FormatGemini Format = "gemini" FormatGeminiCLI Format = "gemini-cli" FormatCodex Format = "codex" + FormatAntigravity Format = "antigravity" )