Compare commits
3 Commits
v6.6.106
...
v6.6.109-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7248f65c36 | ||
|
|
086eb3df7a | ||
|
|
5a7e5bd870 |
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
@@ -1104,12 +1105,49 @@ func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyau
|
|||||||
auth.Metadata["refresh_token"] = tokenResp.RefreshToken
|
auth.Metadata["refresh_token"] = tokenResp.RefreshToken
|
||||||
}
|
}
|
||||||
auth.Metadata["expires_in"] = tokenResp.ExpiresIn
|
auth.Metadata["expires_in"] = tokenResp.ExpiresIn
|
||||||
auth.Metadata["timestamp"] = time.Now().UnixMilli()
|
now := time.Now()
|
||||||
auth.Metadata["expired"] = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339)
|
auth.Metadata["timestamp"] = now.UnixMilli()
|
||||||
|
auth.Metadata["expired"] = now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339)
|
||||||
auth.Metadata["type"] = antigravityAuthType
|
auth.Metadata["type"] = antigravityAuthType
|
||||||
|
if errProject := e.ensureAntigravityProjectID(ctx, auth, tokenResp.AccessToken); errProject != nil {
|
||||||
|
log.Warnf("antigravity executor: ensure project id failed: %v", errProject)
|
||||||
|
}
|
||||||
return auth, nil
|
return auth, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *AntigravityExecutor) ensureAntigravityProjectID(ctx context.Context, auth *cliproxyauth.Auth, accessToken string) error {
|
||||||
|
if auth == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth.Metadata["project_id"] != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(accessToken)
|
||||||
|
if token == "" {
|
||||||
|
token = metaStringValue(auth.Metadata, "access_token")
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
projectID, errFetch := sdkAuth.FetchAntigravityProjectID(ctx, token, httpClient)
|
||||||
|
if errFetch != nil {
|
||||||
|
return errFetch
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(projectID) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if auth.Metadata == nil {
|
||||||
|
auth.Metadata = make(map[string]any)
|
||||||
|
}
|
||||||
|
auth.Metadata["project_id"] = strings.TrimSpace(projectID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyauth.Auth, token, modelName string, payload []byte, stream bool, alt, baseURL string) (*http.Request, error) {
|
func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyauth.Auth, token, modelName string, payload []byte, stream bool, alt, baseURL string) (*http.Request, error) {
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
|
return nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ func fetchAntigravityProjectID(ctx context.Context, accessToken string, httpClie
|
|||||||
// Call loadCodeAssist to get the project
|
// Call loadCodeAssist to get the project
|
||||||
loadReqBody := map[string]any{
|
loadReqBody := map[string]any{
|
||||||
"metadata": map[string]string{
|
"metadata": map[string]string{
|
||||||
"ideType": "IDE_UNSPECIFIED",
|
"ideType": "ANTIGRAVITY",
|
||||||
"platform": "PLATFORM_UNSPECIFIED",
|
"platform": "PLATFORM_UNSPECIFIED",
|
||||||
"pluginType": "GEMINI",
|
"pluginType": "GEMINI",
|
||||||
},
|
},
|
||||||
@@ -442,8 +442,134 @@ func fetchAntigravityProjectID(ctx context.Context, accessToken string, httpClie
|
|||||||
}
|
}
|
||||||
|
|
||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
return "", fmt.Errorf("no cloudaicompanionProject in response")
|
tierID := "legacy-tier"
|
||||||
|
if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers {
|
||||||
|
for _, rawTier := range tiers {
|
||||||
|
tier, okTier := rawTier.(map[string]any)
|
||||||
|
if !okTier {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isDefault, okDefault := tier["isDefault"].(bool); okDefault && isDefault {
|
||||||
|
if id, okID := tier["id"].(string); okID && strings.TrimSpace(id) != "" {
|
||||||
|
tierID = strings.TrimSpace(id)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID, err = antigravityOnboardUser(ctx, accessToken, tierID, httpClient)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return projectID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return projectID, nil
|
return projectID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// antigravityOnboardUser attempts to fetch the project ID via onboardUser by polling for completion.
|
||||||
|
// It returns an empty string when the operation times out or completes without a project ID.
|
||||||
|
func antigravityOnboardUser(ctx context.Context, accessToken, tierID string, httpClient *http.Client) (string, error) {
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = http.DefaultClient
|
||||||
|
}
|
||||||
|
fmt.Println("Antigravity: onboarding user...", tierID)
|
||||||
|
requestBody := map[string]any{
|
||||||
|
"tierId": tierID,
|
||||||
|
"metadata": map[string]string{
|
||||||
|
"ideType": "ANTIGRAVITY",
|
||||||
|
"platform": "PLATFORM_UNSPECIFIED",
|
||||||
|
"pluginType": "GEMINI",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rawBody, errMarshal := json.Marshal(requestBody)
|
||||||
|
if errMarshal != nil {
|
||||||
|
return "", fmt.Errorf("marshal request body: %w", errMarshal)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxAttempts := 5
|
||||||
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
|
log.Debugf("Polling attempt %d/%d", attempt, maxAttempts)
|
||||||
|
|
||||||
|
reqCtx := ctx
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
if reqCtx == nil {
|
||||||
|
reqCtx = context.Background()
|
||||||
|
}
|
||||||
|
reqCtx, cancel = context.WithTimeout(reqCtx, 30*time.Second)
|
||||||
|
|
||||||
|
endpointURL := fmt.Sprintf("%s/%s:onboardUser", antigravityAPIEndpoint, antigravityAPIVersion)
|
||||||
|
req, errRequest := http.NewRequestWithContext(reqCtx, http.MethodPost, endpointURL, strings.NewReader(string(rawBody)))
|
||||||
|
if errRequest != nil {
|
||||||
|
cancel()
|
||||||
|
return "", fmt.Errorf("create request: %w", errRequest)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", antigravityAPIUserAgent)
|
||||||
|
req.Header.Set("X-Goog-Api-Client", antigravityAPIClient)
|
||||||
|
req.Header.Set("Client-Metadata", antigravityClientMetadata)
|
||||||
|
|
||||||
|
resp, errDo := httpClient.Do(req)
|
||||||
|
if errDo != nil {
|
||||||
|
cancel()
|
||||||
|
return "", fmt.Errorf("execute request: %w", errDo)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, errRead := io.ReadAll(resp.Body)
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("close body error: %v", errClose)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if errRead != nil {
|
||||||
|
return "", fmt.Errorf("read response: %w", errRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
var data map[string]any
|
||||||
|
if errDecode := json.Unmarshal(bodyBytes, &data); errDecode != nil {
|
||||||
|
return "", fmt.Errorf("decode response: %w", errDecode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if done, okDone := data["done"].(bool); okDone && done {
|
||||||
|
projectID := ""
|
||||||
|
if responseData, okResp := data["response"].(map[string]any); okResp {
|
||||||
|
switch projectValue := responseData["cloudaicompanionProject"].(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if id, okID := projectValue["id"].(string); okID {
|
||||||
|
projectID = strings.TrimSpace(id)
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
projectID = strings.TrimSpace(projectValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if projectID != "" {
|
||||||
|
log.Infof("Successfully fetched project_id: %s", projectID)
|
||||||
|
return projectID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no project_id in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
responsePreview := strings.TrimSpace(string(bodyBytes))
|
||||||
|
if len(responsePreview) > 500 {
|
||||||
|
responsePreview = responsePreview[:500]
|
||||||
|
}
|
||||||
|
|
||||||
|
responseErr := responsePreview
|
||||||
|
if len(responseErr) > 200 {
|
||||||
|
responseErr = responseErr[:200]
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("http %d: %s", resp.StatusCode, responseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -77,15 +79,23 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str
|
|||||||
if metadataEqualIgnoringTimestamps(existing, raw) {
|
if metadataEqualIgnoringTimestamps(existing, raw) {
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
} else if errRead != nil && !os.IsNotExist(errRead) {
|
file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||||
|
if errOpen != nil {
|
||||||
|
return "", fmt.Errorf("auth filestore: open existing failed: %w", errOpen)
|
||||||
|
}
|
||||||
|
if _, errWrite := file.Write(raw); errWrite != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return "", fmt.Errorf("auth filestore: write existing failed: %w", errWrite)
|
||||||
|
}
|
||||||
|
if errClose := file.Close(); errClose != nil {
|
||||||
|
return "", fmt.Errorf("auth filestore: close existing failed: %w", errClose)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
} else if !os.IsNotExist(errRead) {
|
||||||
return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead)
|
return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead)
|
||||||
}
|
}
|
||||||
tmp := path + ".tmp"
|
if errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil {
|
||||||
if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {
|
return "", fmt.Errorf("auth filestore: write file failed: %w", errWrite)
|
||||||
return "", fmt.Errorf("auth filestore: write temp failed: %w", errWrite)
|
|
||||||
}
|
|
||||||
if errRename := os.Rename(tmp, path); errRename != nil {
|
|
||||||
return "", fmt.Errorf("auth filestore: rename failed: %w", errRename)
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID)
|
return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID)
|
||||||
@@ -178,6 +188,30 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth,
|
|||||||
if provider == "" {
|
if provider == "" {
|
||||||
provider = "unknown"
|
provider = "unknown"
|
||||||
}
|
}
|
||||||
|
if provider == "antigravity" {
|
||||||
|
projectID := ""
|
||||||
|
if pid, ok := metadata["project_id"].(string); ok {
|
||||||
|
projectID = strings.TrimSpace(pid)
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
accessToken := ""
|
||||||
|
if token, ok := metadata["access_token"].(string); ok {
|
||||||
|
accessToken = strings.TrimSpace(token)
|
||||||
|
}
|
||||||
|
if accessToken != "" {
|
||||||
|
fetchedProjectID, errFetch := FetchAntigravityProjectID(context.Background(), accessToken, http.DefaultClient)
|
||||||
|
if errFetch == nil && strings.TrimSpace(fetchedProjectID) != "" {
|
||||||
|
metadata["project_id"] = strings.TrimSpace(fetchedProjectID)
|
||||||
|
if raw, errMarshal := json.Marshal(metadata); errMarshal == nil {
|
||||||
|
if file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600); errOpen == nil {
|
||||||
|
_, _ = file.Write(raw)
|
||||||
|
_ = file.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("stat file: %w", err)
|
return nil, fmt.Errorf("stat file: %w", err)
|
||||||
@@ -266,92 +300,28 @@ func (s *FileTokenStore) baseDirSnapshot() string {
|
|||||||
return s.baseDir
|
return s.baseDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEPRECATED: Use metadataEqualIgnoringTimestamps for comparing auth metadata.
|
// metadataEqualIgnoringTimestamps compares two metadata JSON blobs, ignoring volatile fields that
|
||||||
// This function is kept for backward compatibility but can cause refresh loops.
|
// change on every refresh but don't affect authentication logic.
|
||||||
func jsonEqual(a, b []byte) bool {
|
|
||||||
var objA any
|
|
||||||
var objB any
|
|
||||||
if err := json.Unmarshal(a, &objA); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(b, &objB); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return deepEqualJSON(objA, objB)
|
|
||||||
}
|
|
||||||
|
|
||||||
// metadataEqualIgnoringTimestamps compares two metadata JSON blobs,
|
|
||||||
// ignoring fields that change on every refresh but don't affect functionality.
|
|
||||||
// This prevents unnecessary file writes that would trigger watcher events and
|
|
||||||
// create refresh loops.
|
|
||||||
func metadataEqualIgnoringTimestamps(a, b []byte) bool {
|
func metadataEqualIgnoringTimestamps(a, b []byte) bool {
|
||||||
var objA, objB map[string]any
|
var objA map[string]any
|
||||||
if err := json.Unmarshal(a, &objA); err != nil {
|
var objB map[string]any
|
||||||
|
if errUnmarshalA := json.Unmarshal(a, &objA); errUnmarshalA != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(b, &objB); err != nil {
|
if errUnmarshalB := json.Unmarshal(b, &objB); errUnmarshalB != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
stripVolatileMetadataFields(objA)
|
||||||
|
stripVolatileMetadataFields(objB)
|
||||||
|
return reflect.DeepEqual(objA, objB)
|
||||||
|
}
|
||||||
|
|
||||||
// Fields to ignore: these change on every refresh but don't affect authentication logic.
|
func stripVolatileMetadataFields(metadata map[string]any) {
|
||||||
// - timestamp, expired, expires_in, last_refresh: time-related fields that change on refresh
|
if metadata == nil {
|
||||||
// - access_token: Google OAuth returns a new access_token on each refresh, this is expected
|
return
|
||||||
// and shouldn't trigger file writes (the new token will be fetched again when needed)
|
|
||||||
ignoredFields := []string{"timestamp", "expired", "expires_in", "last_refresh", "access_token"}
|
|
||||||
for _, field := range ignoredFields {
|
|
||||||
delete(objA, field)
|
|
||||||
delete(objB, field)
|
|
||||||
}
|
}
|
||||||
|
// These fields change on refresh and would otherwise trigger watcher reload loops.
|
||||||
return deepEqualJSON(objA, objB)
|
for _, field := range []string{"timestamp", "expired", "expires_in", "last_refresh", "access_token"} {
|
||||||
}
|
delete(metadata, field)
|
||||||
|
|
||||||
func deepEqualJSON(a, b any) bool {
|
|
||||||
switch valA := a.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
valB, ok := b.(map[string]any)
|
|
||||||
if !ok || len(valA) != len(valB) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for key, subA := range valA {
|
|
||||||
subB, ok1 := valB[key]
|
|
||||||
if !ok1 || !deepEqualJSON(subA, subB) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
case []any:
|
|
||||||
sliceB, ok := b.([]any)
|
|
||||||
if !ok || len(valA) != len(sliceB) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := range valA {
|
|
||||||
if !deepEqualJSON(valA[i], sliceB[i]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
case float64:
|
|
||||||
valB, ok := b.(float64)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return valA == valB
|
|
||||||
case string:
|
|
||||||
valB, ok := b.(string)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return valA == valB
|
|
||||||
case bool:
|
|
||||||
valB, ok := b.(bool)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return valA == valB
|
|
||||||
case nil:
|
|
||||||
return b == nil
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user