Files
CLIProxyAPI/internal/runtime/executor/antigravity_executor_auth.go
hkfires 6a489fa84d fix(auth): prefer errors from upstream attempts
Track when executor calls cross an upstream transport boundary and use that
signal to keep model/provider errors from being replaced by later local
preparation, selection, or internal failures.

Mark HTTP, websocket, relay, and usage-tracked transports as upstream
attempts, while avoiding marks for local validation, logging, missing
sessions, and successful websocket handshakes before request send.

Parse relative auth expiry metadata and adjust Antigravity refresh timing.
2026-08-29 12:50:46 +08:00

277 lines
8.5 KiB
Go

package executor
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
// Refresh refreshes the authentication credentials using the refresh token.
func (e *AntigravityExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
return refreshed, err
}
if auth == nil {
return auth, nil
}
updated, errRefresh := e.refreshToken(ctx, auth.Clone())
if errRefresh != nil {
return nil, errRefresh
}
return updated, nil
}
func (e *AntigravityExecutor) ShouldPrepareRequestAuth(auth *cliproxyauth.Auth) bool {
return antigravityProjectIDFromAuth(auth) == ""
}
func (e *AntigravityExecutor) PrepareRequestAuth(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
if auth == nil || !e.ShouldPrepareRequestAuth(auth) {
return nil, nil
}
updated := auth.Clone()
token, refreshedAuth, errToken := e.ensureAccessToken(ctx, updated)
if errToken != nil {
return nil, errToken
}
if refreshedAuth != nil {
updated = refreshedAuth
}
if antigravityProjectIDFromAuth(updated) != "" {
return updated, nil
}
projectID, errProject := e.fetchAntigravityProjectID(ctx, updated, token)
if errProject != nil {
return nil, missingAntigravityProjectIDError(errProject)
}
if projectID == "" {
return nil, missingAntigravityProjectIDError(nil)
}
if updated.Metadata == nil {
updated.Metadata = make(map[string]any)
}
updated.Metadata["project_id"] = projectID
return updated, nil
}
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, _ := auth.ExpirationTime()
if accessToken != "" && expiry.After(time.Now().Add(antigravityRequestTokenSafetyWindow)) {
e.maybeRefreshAntigravityCreditsHint(ctx, auth, accessToken)
return accessToken, nil, nil
}
refreshCtx := context.Background()
if ctx != nil {
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
refreshCtx = context.WithValue(refreshCtx, "cliproxy.roundtripper", rt)
}
}
if refreshed, handled, err := helps.RefreshAuthViaHome(refreshCtx, e.cfg, auth); handled {
if err != nil {
return "", nil, err
}
token := metaStringValue(refreshed.Metadata, "access_token")
if strings.TrimSpace(token) == "" {
return "", nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
}
e.maybeRefreshAntigravityCreditsHint(ctx, refreshed, token)
return token, refreshed, nil
}
updated, errRefresh := e.refreshToken(refreshCtx, 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"}
}
if ctx == nil {
ctx = context.Background()
}
refreshToken = strings.TrimSpace(refreshToken)
result, errRefresh, _ := antigravityRefreshGroup.Do(refreshToken, func() (interface{}, error) {
return e.refreshTokenSingleFlight(context.WithoutCancel(ctx), auth, refreshToken)
})
if errRefresh != nil {
return auth, errRefresh
}
tokenResp, ok := result.(*antigravityTokenRefreshData)
if !ok || tokenResp == nil {
return auth, fmt.Errorf("antigravity token refresh failed: invalid single-flight result")
}
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
now := time.Now()
auth.Metadata["timestamp"] = now.UnixMilli()
auth.Metadata["expired"] = now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339)
auth.Metadata["type"] = antigravityAuthType
if errProject := e.ensureAntigravityProjectID(ctx, auth, tokenResp.AccessToken); errProject != nil {
log.Warnf("antigravity executor: ensure project id failed: %v", errProject)
}
e.updateAntigravityCreditsBalance(ctx, auth, tokenResp.AccessToken)
return auth, nil
}
func (e *AntigravityExecutor) refreshTokenSingleFlight(ctx context.Context, auth *cliproxyauth.Auth, refreshToken string) (*antigravityTokenRefreshData, error) {
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 nil, errReq
}
httpReq.Header.Set("Host", "oauth2.googleapis.com")
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// Real Antigravity uses Go's default User-Agent for OAuth token refresh
httpReq.Header.Set("User-Agent", "Go-http-client/2.0")
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
return nil, 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 nil, errRead
}
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
sErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
if httpResp.StatusCode == http.StatusTooManyRequests {
if retryAfter, parseErr := helps.ParseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {
sErr.retryAfter = retryAfter
}
}
return nil, sErr
}
var tokenResp antigravityTokenRefreshData
if errUnmarshal := json.Unmarshal(bodyBytes, &tokenResp); errUnmarshal != nil {
return nil, errUnmarshal
}
return &tokenResp, nil
}
func (e *AntigravityExecutor) ensureAntigravityProjectID(ctx context.Context, auth *cliproxyauth.Auth, accessToken string) error {
if auth == nil {
return nil
}
if antigravityProjectIDFromAuth(auth) != "" {
return nil
}
projectID, errFetch := e.fetchAntigravityProjectID(ctx, auth, accessToken)
if errFetch != nil {
return errFetch
}
if projectID == "" {
return nil
}
if auth.Metadata == nil {
auth.Metadata = make(map[string]any)
}
auth.Metadata["project_id"] = projectID
return nil
}
func (e *AntigravityExecutor) fetchAntigravityProjectID(ctx context.Context, auth *cliproxyauth.Auth, accessToken string) (string, error) {
token := strings.TrimSpace(accessToken)
if token == "" {
token = metaStringValue(auth.Metadata, "access_token")
}
if token == "" {
return "", nil
}
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
projectID, errFetch := sdkAuth.FetchAntigravityProjectID(ctx, token, httpClient)
if errFetch != nil {
return "", errFetch
}
return strings.TrimSpace(projectID), nil
}
func (e *AntigravityExecutor) projectIDForRequest(_ context.Context, auth *cliproxyauth.Auth, _ string) (string, error) {
if projectID := antigravityProjectIDFromAuth(auth); projectID != "" {
return projectID, nil
}
return "", missingAntigravityProjectIDError(nil)
}
func antigravityProjectIDFromAuth(auth *cliproxyauth.Auth) string {
if auth == nil || auth.Metadata == nil {
return ""
}
if pid, ok := auth.Metadata["project_id"].(string); ok {
return strings.TrimSpace(pid)
}
return ""
}
func missingAntigravityProjectIDError(cause error) statusErr {
msg := "antigravity auth missing project_id"
if cause != nil {
msg = fmt.Sprintf("%s: %v", msg, cause)
}
return statusErr{code: http.StatusBadRequest, msg: msg}
}
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 ""
}