mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 13:00:52 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
327cc7039e | ||
|
|
b4d15ace91 | ||
|
|
abc2465b29 | ||
|
|
4ba5b43d82 | ||
|
|
27faf718a3 | ||
|
|
2d84d2fb6a | ||
|
|
cbcfeb92cc |
@@ -61,6 +61,7 @@ func main() {
|
|||||||
var iflowLogin bool
|
var iflowLogin bool
|
||||||
var iflowCookie bool
|
var iflowCookie bool
|
||||||
var noBrowser bool
|
var noBrowser bool
|
||||||
|
var antigravityLogin bool
|
||||||
var projectID string
|
var projectID string
|
||||||
var vertexImport string
|
var vertexImport string
|
||||||
var configPath string
|
var configPath string
|
||||||
@@ -74,6 +75,7 @@ func main() {
|
|||||||
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
|
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
|
||||||
flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
|
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(&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(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
|
||||||
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
|
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
|
||||||
flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
|
flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
|
||||||
@@ -431,6 +433,9 @@ func main() {
|
|||||||
} else if login {
|
} else if login {
|
||||||
// Handle Google/Gemini login
|
// Handle Google/Gemini login
|
||||||
cmd.DoLogin(cfg, projectID, options)
|
cmd.DoLogin(cfg, projectID, options)
|
||||||
|
} else if antigravityLogin {
|
||||||
|
// Handle Antigravity login
|
||||||
|
cmd.DoAntigravityLogin(cfg, options)
|
||||||
} else if codexLogin {
|
} else if codexLogin {
|
||||||
// Handle Codex login
|
// Handle Codex login
|
||||||
cmd.DoCodexLogin(cfg, options)
|
cmd.DoCodexLogin(cfg, options)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ services:
|
|||||||
- "8085:8085"
|
- "8085:8085"
|
||||||
- "1455:1455"
|
- "1455:1455"
|
||||||
- "54545:54545"
|
- "54545:54545"
|
||||||
|
- "51121:51121"
|
||||||
- "11451:11451"
|
- "11451:11451"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/CLIProxyAPI/config.yaml
|
- ./config.yaml:/CLIProxyAPI/config.yaml
|
||||||
|
|||||||
38
internal/cmd/antigravity_login.go
Normal file
38
internal/cmd/antigravity_login.go
Normal file
@@ -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!")
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ func newAuthManager() *sdkAuth.Manager {
|
|||||||
sdkAuth.NewClaudeAuthenticator(),
|
sdkAuth.NewClaudeAuthenticator(),
|
||||||
sdkAuth.NewQwenAuthenticator(),
|
sdkAuth.NewQwenAuthenticator(),
|
||||||
sdkAuth.NewIFlowAuthenticator(),
|
sdkAuth.NewIFlowAuthenticator(),
|
||||||
|
sdkAuth.NewAntigravityAuthenticator(),
|
||||||
)
|
)
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
|
|||||||
case wsrelay.MessageTypeStreamChunk:
|
case wsrelay.MessageTypeStreamChunk:
|
||||||
if len(event.Payload) > 0 {
|
if len(event.Payload) > 0 {
|
||||||
appendAPIResponseChunk(ctx, e.cfg, bytes.Clone(event.Payload))
|
appendAPIResponseChunk(ctx, e.cfg, bytes.Clone(event.Payload))
|
||||||
filtered := filterAIStudioUsageMetadata(event.Payload)
|
filtered := FilterSSEUsageMetadata(event.Payload)
|
||||||
if detail, ok := parseGeminiStreamUsage(filtered); ok {
|
if detail, ok := parseGeminiStreamUsage(filtered); ok {
|
||||||
reporter.publish(ctx, detail)
|
reporter.publish(ctx, detail)
|
||||||
}
|
}
|
||||||
@@ -264,6 +264,7 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c
|
|||||||
}
|
}
|
||||||
payload = util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride)
|
payload = util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride)
|
||||||
}
|
}
|
||||||
|
payload = util.ConvertThinkingLevelToBudget(payload)
|
||||||
payload = util.StripThinkingConfigIfUnsupported(req.Model, payload)
|
payload = util.StripThinkingConfigIfUnsupported(req.Model, payload)
|
||||||
payload = fixGeminiImageAspectRatio(req.Model, payload)
|
payload = fixGeminiImageAspectRatio(req.Model, payload)
|
||||||
payload = applyPayloadConfig(e.cfg, req.Model, payload)
|
payload = applyPayloadConfig(e.cfg, req.Model, payload)
|
||||||
@@ -295,65 +296,6 @@ func (e *AIStudioExecutor) buildEndpoint(model, action, alt string) string {
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterAIStudioUsageMetadata removes usageMetadata from intermediate SSE events so that
|
|
||||||
// only the terminal chunk retains token statistics.
|
|
||||||
func filterAIStudioUsageMetadata(payload []byte) []byte {
|
|
||||||
if len(payload) == 0 {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := bytes.Split(payload, []byte("\n"))
|
|
||||||
modified := false
|
|
||||||
for idx, line := range lines {
|
|
||||||
trimmed := bytes.TrimSpace(line)
|
|
||||||
if len(trimmed) == 0 || !bytes.HasPrefix(trimmed, []byte("data:")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dataIdx := bytes.Index(line, []byte("data:"))
|
|
||||||
if dataIdx < 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rawJSON := bytes.TrimSpace(line[dataIdx+5:])
|
|
||||||
cleaned, changed := stripUsageMetadataFromJSON(rawJSON)
|
|
||||||
if !changed {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var rebuilt []byte
|
|
||||||
rebuilt = append(rebuilt, line[:dataIdx]...)
|
|
||||||
rebuilt = append(rebuilt, []byte("data:")...)
|
|
||||||
if len(cleaned) > 0 {
|
|
||||||
rebuilt = append(rebuilt, ' ')
|
|
||||||
rebuilt = append(rebuilt, cleaned...)
|
|
||||||
}
|
|
||||||
lines[idx] = rebuilt
|
|
||||||
modified = true
|
|
||||||
}
|
|
||||||
if !modified {
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
return bytes.Join(lines, []byte("\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripUsageMetadataFromJSON drops usageMetadata when no finishReason is present.
|
|
||||||
func stripUsageMetadataFromJSON(rawJSON []byte) ([]byte, bool) {
|
|
||||||
jsonBytes := bytes.TrimSpace(rawJSON)
|
|
||||||
if len(jsonBytes) == 0 || !gjson.ValidBytes(jsonBytes) {
|
|
||||||
return rawJSON, false
|
|
||||||
}
|
|
||||||
finishReason := gjson.GetBytes(jsonBytes, "candidates.0.finishReason")
|
|
||||||
if finishReason.Exists() && finishReason.String() != "" {
|
|
||||||
return rawJSON, false
|
|
||||||
}
|
|
||||||
if !gjson.GetBytes(jsonBytes, "usageMetadata").Exists() {
|
|
||||||
return rawJSON, false
|
|
||||||
}
|
|
||||||
cleaned, err := sjson.DeleteBytes(jsonBytes, "usageMetadata")
|
|
||||||
if err != nil {
|
|
||||||
return rawJSON, false
|
|
||||||
}
|
|
||||||
return cleaned, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureColonSpacedJSON normalizes JSON objects so that colons are followed by a single space while
|
// ensureColonSpacedJSON normalizes JSON objects so that colons are followed by a single space while
|
||||||
// keeping the payload otherwise compact. Non-JSON inputs are returned unchanged.
|
// keeping the payload otherwise compact. Non-JSON inputs are returned unchanged.
|
||||||
func ensureColonSpacedJSON(payload []byte) []byte {
|
func ensureColonSpacedJSON(payload []byte) []byte {
|
||||||
|
|||||||
565
internal/runtime/executor/antigravity_executor.go
Normal file
565
internal/runtime/executor/antigravity_executor.go
Normal file
@@ -0,0 +1,565 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
// Filter usage metadata for all models
|
||||||
|
// Only retain usage statistics in the terminal chunk
|
||||||
|
line = FilterSSEUsageMetadata(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
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
type usageReporter struct {
|
type usageReporter struct {
|
||||||
@@ -383,3 +384,94 @@ func jsonPayload(line []byte) []byte {
|
|||||||
}
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FilterSSEUsageMetadata removes usageMetadata from intermediate SSE events so that
|
||||||
|
// only the terminal chunk retains token statistics.
|
||||||
|
// This function is shared between aistudio and antigravity executors.
|
||||||
|
func FilterSSEUsageMetadata(payload []byte) []byte {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := bytes.Split(payload, []byte("\n"))
|
||||||
|
modified := false
|
||||||
|
for idx, line := range lines {
|
||||||
|
trimmed := bytes.TrimSpace(line)
|
||||||
|
if len(trimmed) == 0 || !bytes.HasPrefix(trimmed, []byte("data:")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dataIdx := bytes.Index(line, []byte("data:"))
|
||||||
|
if dataIdx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rawJSON := bytes.TrimSpace(line[dataIdx+5:])
|
||||||
|
cleaned, changed := StripUsageMetadataFromJSON(rawJSON)
|
||||||
|
if !changed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rebuilt []byte
|
||||||
|
rebuilt = append(rebuilt, line[:dataIdx]...)
|
||||||
|
rebuilt = append(rebuilt, []byte("data:")...)
|
||||||
|
if len(cleaned) > 0 {
|
||||||
|
rebuilt = append(rebuilt, ' ')
|
||||||
|
rebuilt = append(rebuilt, cleaned...)
|
||||||
|
}
|
||||||
|
lines[idx] = rebuilt
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
if !modified {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
return bytes.Join(lines, []byte("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripUsageMetadataFromJSON drops usageMetadata when no finishReason is present.
|
||||||
|
// This function is shared between aistudio and antigravity executors.
|
||||||
|
// It handles both formats:
|
||||||
|
// - Aistudio: candidates.0.finishReason
|
||||||
|
// - Antigravity: response.candidates.0.finishReason
|
||||||
|
func StripUsageMetadataFromJSON(rawJSON []byte) ([]byte, bool) {
|
||||||
|
jsonBytes := bytes.TrimSpace(rawJSON)
|
||||||
|
if len(jsonBytes) == 0 || !gjson.ValidBytes(jsonBytes) {
|
||||||
|
return rawJSON, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for finishReason in both aistudio and antigravity formats
|
||||||
|
finishReason := gjson.GetBytes(jsonBytes, "candidates.0.finishReason")
|
||||||
|
if !finishReason.Exists() {
|
||||||
|
finishReason = gjson.GetBytes(jsonBytes, "response.candidates.0.finishReason")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If finishReason exists and is not empty, keep the usageMetadata
|
||||||
|
if finishReason.Exists() && finishReason.String() != "" {
|
||||||
|
return rawJSON, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for usageMetadata in both possible locations
|
||||||
|
usageMetadata := gjson.GetBytes(jsonBytes, "usageMetadata")
|
||||||
|
if !usageMetadata.Exists() {
|
||||||
|
usageMetadata = gjson.GetBytes(jsonBytes, "response.usageMetadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !usageMetadata.Exists() {
|
||||||
|
return rawJSON, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove usageMetadata from both possible locations
|
||||||
|
cleaned := jsonBytes
|
||||||
|
var changed bool
|
||||||
|
|
||||||
|
// Try to remove usageMetadata from root level
|
||||||
|
if gjson.GetBytes(cleaned, "usageMetadata").Exists() {
|
||||||
|
cleaned, _ = sjson.DeleteBytes(cleaned, "usageMetadata")
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to remove usageMetadata from response level
|
||||||
|
if gjson.GetBytes(cleaned, "response.usageMetadata").Exists() {
|
||||||
|
cleaned, _ = sjson.DeleteBytes(cleaned, "response.usageMetadata")
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned, changed
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
package gemini
|
package gemini
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ import (
|
|||||||
"github.com/tidwall/sjson"
|
"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
|
// 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.
|
// from the raw JSON request and returns them in the format expected by the Gemini API.
|
||||||
// The function performs the following transformations:
|
// The function performs the following transformations:
|
||||||
@@ -29,7 +30,11 @@ import (
|
|||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - []string: The transformed request data in Gemini API format
|
// - []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 {
|
if alt, ok := ctx.Value("alt").(string); ok {
|
||||||
var chunk []byte
|
var chunk []byte
|
||||||
if alt == "" {
|
if alt == "" {
|
||||||
@@ -56,7 +61,7 @@ func ConvertGeminiCliRequestToGemini(ctx context.Context, _ string, originalRequ
|
|||||||
return []string{}
|
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
|
// 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.
|
// 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:
|
// Returns:
|
||||||
// - string: A Gemini-compatible JSON response containing the response data
|
// - 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")
|
responseResult := gjson.GetBytes(rawJSON, "response")
|
||||||
if responseResult.Exists() {
|
if responseResult.Exists() {
|
||||||
return responseResult.Raw
|
return responseResult.Raw
|
||||||
@@ -12,8 +12,8 @@ func init() {
|
|||||||
GeminiCLI,
|
GeminiCLI,
|
||||||
ConvertGeminiRequestToGeminiCLI,
|
ConvertGeminiRequestToGeminiCLI,
|
||||||
interfaces.TranslateResponse{
|
interfaces.TranslateResponse{
|
||||||
Stream: ConvertGeminiCliRequestToGemini,
|
Stream: ConvertGeminiCliResponseToGemini,
|
||||||
NonStream: ConvertGeminiCliRequestToGeminiNonStream,
|
NonStream: ConvertGeminiCliResponseToGeminiNonStream,
|
||||||
TokenCount: GeminiTokenCount,
|
TokenCount: GeminiTokenCount,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -98,25 +98,40 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
|
|||||||
// Process the main content part of the response.
|
// Process the main content part of the response.
|
||||||
partsResult := gjson.GetBytes(rawJSON, "response.candidates.0.content.parts")
|
partsResult := gjson.GetBytes(rawJSON, "response.candidates.0.content.parts")
|
||||||
hasFunctionCall := false
|
hasFunctionCall := false
|
||||||
|
hasValidContent := false
|
||||||
if partsResult.IsArray() {
|
if partsResult.IsArray() {
|
||||||
partResults := partsResult.Array()
|
partResults := partsResult.Array()
|
||||||
for i := 0; i < len(partResults); i++ {
|
for i := 0; i < len(partResults); i++ {
|
||||||
partResult := partResults[i]
|
partResult := partResults[i]
|
||||||
partTextResult := partResult.Get("text")
|
partTextResult := partResult.Get("text")
|
||||||
functionCallResult := partResult.Get("functionCall")
|
functionCallResult := partResult.Get("functionCall")
|
||||||
|
thoughtSignatureResult := partResult.Get("thoughtSignature")
|
||||||
inlineDataResult := partResult.Get("inlineData")
|
inlineDataResult := partResult.Get("inlineData")
|
||||||
if !inlineDataResult.Exists() {
|
if !inlineDataResult.Exists() {
|
||||||
inlineDataResult = partResult.Get("inline_data")
|
inlineDataResult = partResult.Get("inline_data")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle thoughtSignature - this is encrypted reasoning content that should not be exposed to the client
|
||||||
|
if thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" {
|
||||||
|
// Skip thoughtSignature processing - it's internal encrypted data
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if partTextResult.Exists() {
|
if partTextResult.Exists() {
|
||||||
|
textContent := partTextResult.String()
|
||||||
|
// Skip empty text content to avoid generating unnecessary chunks
|
||||||
|
if textContent == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Handle text content, distinguishing between regular content and reasoning/thoughts.
|
// Handle text content, distinguishing between regular content and reasoning/thoughts.
|
||||||
if partResult.Get("thought").Bool() {
|
if partResult.Get("thought").Bool() {
|
||||||
template, _ = sjson.Set(template, "choices.0.delta.reasoning_content", partTextResult.String())
|
template, _ = sjson.Set(template, "choices.0.delta.reasoning_content", textContent)
|
||||||
} else {
|
} else {
|
||||||
template, _ = sjson.Set(template, "choices.0.delta.content", partTextResult.String())
|
template, _ = sjson.Set(template, "choices.0.delta.content", textContent)
|
||||||
}
|
}
|
||||||
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
||||||
|
hasValidContent = true
|
||||||
} else if functionCallResult.Exists() {
|
} else if functionCallResult.Exists() {
|
||||||
// Handle function call content.
|
// Handle function call content.
|
||||||
hasFunctionCall = true
|
hasFunctionCall = true
|
||||||
@@ -176,6 +191,12 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
|
|||||||
template, _ = sjson.Set(template, "choices.0.native_finish_reason", "tool_calls")
|
template, _ = sjson.Set(template, "choices.0.native_finish_reason", "tool_calls")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only return a chunk if there's actual content or a finish reason
|
||||||
|
finishReason := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason")
|
||||||
|
if !hasValidContent && !finishReason.Exists() {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
return []string{template}
|
return []string{template}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,3 +213,49 @@ func StripThinkingConfigIfUnsupported(model string, body []byte) []byte {
|
|||||||
updated, _ = sjson.DeleteBytes(updated, "generationConfig.thinkingConfig")
|
updated, _ = sjson.DeleteBytes(updated, "generationConfig.thinkingConfig")
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConvertThinkingLevelToBudget checks for "generationConfig.thinkingConfig.thinkingLevel"
|
||||||
|
// and converts it to "thinkingBudget".
|
||||||
|
// "high" -> 32768
|
||||||
|
// "low" -> 128
|
||||||
|
// It removes "thinkingLevel" after conversion.
|
||||||
|
func ConvertThinkingLevelToBudget(body []byte) []byte {
|
||||||
|
levelPath := "generationConfig.thinkingConfig.thinkingLevel"
|
||||||
|
res := gjson.GetBytes(body, levelPath)
|
||||||
|
if !res.Exists() {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
level := strings.ToLower(res.String())
|
||||||
|
var budget int
|
||||||
|
switch level {
|
||||||
|
case "high":
|
||||||
|
budget = 32768
|
||||||
|
case "low":
|
||||||
|
budget = 128
|
||||||
|
default:
|
||||||
|
// If unknown level, we might just leave it or default.
|
||||||
|
// User only specified high and low. We'll assume we shouldn't touch it if it's something else,
|
||||||
|
// or maybe we should just remove the invalid level?
|
||||||
|
// For safety adhering to strict instructions: "If high... if low...".
|
||||||
|
// If it's something else, the upstream might fail anyway if we leave it,
|
||||||
|
// but let's just delete the level if we processed it.
|
||||||
|
// Actually, let's check if we need to do anything for other values.
|
||||||
|
// For now, only handle high/low.
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set budget
|
||||||
|
budgetPath := "generationConfig.thinkingConfig.thinkingBudget"
|
||||||
|
updated, err := sjson.SetBytes(body, budgetPath, budget)
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove level
|
||||||
|
updated, err = sjson.DeleteBytes(updated, levelPath)
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|||||||
293
sdk/auth/antigravity.go
Normal file
293
sdk/auth/antigravity.go
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
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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"
|
||||||
|
antigravityCallbackPort = 51121
|
||||||
|
)
|
||||||
|
|
||||||
|
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{}
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := util.SetProxy(&cfg.SDKConfig, &http.Client{})
|
||||||
|
|
||||||
|
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, httpClient)
|
||||||
|
if errToken != nil {
|
||||||
|
return nil, fmt.Errorf("antigravity: token exchange failed: %w", errToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
email := ""
|
||||||
|
if tokenResp.AccessToken != "" {
|
||||||
|
if info, errInfo := fetchAntigravityUserInfo(ctx, tokenResp.AccessToken, httpClient); 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) {
|
||||||
|
addr := fmt.Sprintf(":%d", antigravityCallbackPort)
|
||||||
|
listener, err := net.Listen("tcp", addr)
|
||||||
|
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("<h1>Login successful</h1><p>You can close this window.</p>"))
|
||||||
|
} else {
|
||||||
|
_, _ = w.Write([]byte("<h1>Login failed</h1><p>Please check the CLI output.</p>"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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, httpClient *http.Client) (*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 := httpClient.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, httpClient *http.Client) (*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 := httpClient.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))
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ func init() {
|
|||||||
registerRefreshLead("iflow", func() Authenticator { return NewIFlowAuthenticator() })
|
registerRefreshLead("iflow", func() Authenticator { return NewIFlowAuthenticator() })
|
||||||
registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() })
|
registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() })
|
||||||
registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })
|
registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })
|
||||||
|
registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerRefreshLead(provider string, factory func() Authenticator) {
|
func registerRefreshLead(provider string, factory func() Authenticator) {
|
||||||
|
|||||||
@@ -333,6 +333,8 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) {
|
|||||||
s.coreManager.RegisterExecutor(executor.NewAIStudioExecutor(s.cfg, a.ID, s.wsGateway))
|
s.coreManager.RegisterExecutor(executor.NewAIStudioExecutor(s.cfg, a.ID, s.wsGateway))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
case "antigravity":
|
||||||
|
s.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(s.cfg))
|
||||||
case "claude":
|
case "claude":
|
||||||
s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg))
|
s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg))
|
||||||
case "codex":
|
case "codex":
|
||||||
@@ -634,6 +636,10 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
models = registry.GetGeminiCLIModels()
|
models = registry.GetGeminiCLIModels()
|
||||||
case "aistudio":
|
case "aistudio":
|
||||||
models = registry.GetAIStudioModels()
|
models = registry.GetAIStudioModels()
|
||||||
|
case "antigravity":
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
models = executor.FetchAntigravityModels(ctx, a, s.cfg)
|
||||||
|
cancel()
|
||||||
case "claude":
|
case "claude":
|
||||||
models = registry.GetClaudeModels()
|
models = registry.GetClaudeModels()
|
||||||
if entry := s.resolveConfigClaudeKey(a); entry != nil && len(entry.Models) > 0 {
|
if entry := s.resolveConfigClaudeKey(a); entry != nil && len(entry.Models) > 0 {
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ const (
|
|||||||
FormatGemini Format = "gemini"
|
FormatGemini Format = "gemini"
|
||||||
FormatGeminiCLI Format = "gemini-cli"
|
FormatGeminiCLI Format = "gemini-cli"
|
||||||
FormatCodex Format = "codex"
|
FormatCodex Format = "codex"
|
||||||
|
FormatAntigravity Format = "antigravity"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user