feat: 添加 Antigravity (Cloud AI Companion) OAuth 授权支持
This commit is contained in:
@@ -29,26 +29,26 @@ type Application struct {
|
||||
|
||||
func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
wire.Build(
|
||||
// 基础设施层 ProviderSets
|
||||
// Infrastructure layer ProviderSets
|
||||
config.ProviderSet,
|
||||
infrastructure.ProviderSet,
|
||||
|
||||
// 业务层 ProviderSets
|
||||
// Business layer ProviderSets
|
||||
repository.ProviderSet,
|
||||
service.ProviderSet,
|
||||
middleware.ProviderSet,
|
||||
handler.ProviderSet,
|
||||
|
||||
// 服务器层 ProviderSet
|
||||
// Server layer ProviderSet
|
||||
server.ProviderSet,
|
||||
|
||||
// BuildInfo provider
|
||||
provideServiceBuildInfo,
|
||||
|
||||
// 清理函数提供者
|
||||
// Cleanup function provider
|
||||
provideCleanup,
|
||||
|
||||
// 应用程序结构体
|
||||
// Application struct
|
||||
wire.Struct(new(Application), "Server", "Cleanup"),
|
||||
)
|
||||
return nil, nil
|
||||
@@ -70,6 +70,7 @@ func provideCleanup(
|
||||
oauth *service.OAuthService,
|
||||
openaiOAuth *service.OpenAIOAuthService,
|
||||
geminiOAuth *service.GeminiOAuthService,
|
||||
antigravityOAuth *service.AntigravityOAuthService,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -104,6 +105,10 @@ func provideCleanup(
|
||||
geminiOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"AntigravityOAuthService", func() error {
|
||||
antigravityOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"Redis", func() error {
|
||||
return rdb.Close()
|
||||
}},
|
||||
|
||||
@@ -97,6 +97,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
||||
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
|
||||
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
||||
proxyHandler := admin.NewProxyHandler(adminService)
|
||||
adminRedeemHandler := admin.NewRedeemHandler(adminService)
|
||||
settingHandler := admin.NewSettingHandler(settingService, emailService)
|
||||
@@ -107,7 +109,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
systemHandler := handler.ProvideSystemHandler(updateService)
|
||||
adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService)
|
||||
adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler)
|
||||
gatewayCache := repository.NewGatewayCache(client)
|
||||
pricingRemoteClient := repository.NewPricingRemoteClient()
|
||||
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
|
||||
@@ -132,7 +134,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService)
|
||||
httpServer := server.ProvideHTTPServer(configConfig, engine)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
v := provideCleanup(db, client, tokenRefreshService, pricingService, emailQueueService, oAuthService, openAIOAuthService, geminiOAuthService)
|
||||
v := provideCleanup(db, client, tokenRefreshService, pricingService, emailQueueService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
Cleanup: v,
|
||||
@@ -163,6 +165,7 @@ func provideCleanup(
|
||||
oauth *service.OAuthService,
|
||||
openaiOAuth *service.OpenAIOAuthService,
|
||||
geminiOAuth *service.GeminiOAuthService,
|
||||
antigravityOAuth *service.AntigravityOAuthService,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -196,6 +199,10 @@ func provideCleanup(
|
||||
geminiOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"AntigravityOAuthService", func() error {
|
||||
antigravityOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"Redis", func() error {
|
||||
return rdb.Close()
|
||||
}},
|
||||
|
||||
67
backend/internal/handler/admin/antigravity_oauth_handler.go
Normal file
67
backend/internal/handler/admin/antigravity_oauth_handler.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AntigravityOAuthHandler struct {
|
||||
antigravityOAuthService *service.AntigravityOAuthService
|
||||
}
|
||||
|
||||
func NewAntigravityOAuthHandler(antigravityOAuthService *service.AntigravityOAuthService) *AntigravityOAuthHandler {
|
||||
return &AntigravityOAuthHandler{antigravityOAuthService: antigravityOAuthService}
|
||||
}
|
||||
|
||||
type AntigravityGenerateAuthURLRequest struct {
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
// GenerateAuthURL generates Google OAuth authorization URL
|
||||
// POST /api/v1/admin/antigravity/oauth/auth-url
|
||||
func (h *AntigravityOAuthHandler) GenerateAuthURL(c *gin.Context) {
|
||||
var req AntigravityGenerateAuthURLRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "请求无效: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.antigravityOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID)
|
||||
if err != nil {
|
||||
response.InternalError(c, "生成授权链接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
type AntigravityExchangeCodeRequest struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
State string `json:"state" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
// ExchangeCode 用 authorization code 交换 token
|
||||
// POST /api/v1/admin/antigravity/oauth/exchange-code
|
||||
func (h *AntigravityOAuthHandler) ExchangeCode(c *gin.Context) {
|
||||
var req AntigravityExchangeCodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "请求无效: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tokenInfo, err := h.antigravityOAuthService.ExchangeCode(c.Request.Context(), &service.AntigravityExchangeCodeInput{
|
||||
SessionID: req.SessionID,
|
||||
State: req.State,
|
||||
Code: req.Code,
|
||||
ProxyID: req.ProxyID,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Token 交换失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, tokenInfo)
|
||||
}
|
||||
@@ -6,19 +6,20 @@ import (
|
||||
|
||||
// AdminHandlers contains all admin-related HTTP handlers
|
||||
type AdminHandlers struct {
|
||||
Dashboard *admin.DashboardHandler
|
||||
User *admin.UserHandler
|
||||
Group *admin.GroupHandler
|
||||
Account *admin.AccountHandler
|
||||
OAuth *admin.OAuthHandler
|
||||
OpenAIOAuth *admin.OpenAIOAuthHandler
|
||||
GeminiOAuth *admin.GeminiOAuthHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Setting *admin.SettingHandler
|
||||
System *admin.SystemHandler
|
||||
Subscription *admin.SubscriptionHandler
|
||||
Usage *admin.UsageHandler
|
||||
Dashboard *admin.DashboardHandler
|
||||
User *admin.UserHandler
|
||||
Group *admin.GroupHandler
|
||||
Account *admin.AccountHandler
|
||||
OAuth *admin.OAuthHandler
|
||||
OpenAIOAuth *admin.OpenAIOAuthHandler
|
||||
GeminiOAuth *admin.GeminiOAuthHandler
|
||||
AntigravityOAuth *admin.AntigravityOAuthHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Setting *admin.SettingHandler
|
||||
System *admin.SystemHandler
|
||||
Subscription *admin.SubscriptionHandler
|
||||
Usage *admin.UsageHandler
|
||||
}
|
||||
|
||||
// Handlers contains all HTTP handlers
|
||||
|
||||
@@ -16,6 +16,7 @@ func ProvideAdminHandlers(
|
||||
oauthHandler *admin.OAuthHandler,
|
||||
openaiOAuthHandler *admin.OpenAIOAuthHandler,
|
||||
geminiOAuthHandler *admin.GeminiOAuthHandler,
|
||||
antigravityOAuthHandler *admin.AntigravityOAuthHandler,
|
||||
proxyHandler *admin.ProxyHandler,
|
||||
redeemHandler *admin.RedeemHandler,
|
||||
settingHandler *admin.SettingHandler,
|
||||
@@ -24,19 +25,20 @@ func ProvideAdminHandlers(
|
||||
usageHandler *admin.UsageHandler,
|
||||
) *AdminHandlers {
|
||||
return &AdminHandlers{
|
||||
Dashboard: dashboardHandler,
|
||||
User: userHandler,
|
||||
Group: groupHandler,
|
||||
Account: accountHandler,
|
||||
OAuth: oauthHandler,
|
||||
OpenAIOAuth: openaiOAuthHandler,
|
||||
GeminiOAuth: geminiOAuthHandler,
|
||||
Proxy: proxyHandler,
|
||||
Redeem: redeemHandler,
|
||||
Setting: settingHandler,
|
||||
System: systemHandler,
|
||||
Subscription: subscriptionHandler,
|
||||
Usage: usageHandler,
|
||||
Dashboard: dashboardHandler,
|
||||
User: userHandler,
|
||||
Group: groupHandler,
|
||||
Account: accountHandler,
|
||||
OAuth: oauthHandler,
|
||||
OpenAIOAuth: openaiOAuthHandler,
|
||||
GeminiOAuth: geminiOAuthHandler,
|
||||
AntigravityOAuth: antigravityOAuthHandler,
|
||||
Proxy: proxyHandler,
|
||||
Redeem: redeemHandler,
|
||||
Setting: settingHandler,
|
||||
System: systemHandler,
|
||||
Subscription: subscriptionHandler,
|
||||
Usage: usageHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +100,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewOAuthHandler,
|
||||
admin.NewOpenAIOAuthHandler,
|
||||
admin.NewGeminiOAuthHandler,
|
||||
admin.NewAntigravityOAuthHandler,
|
||||
admin.NewProxyHandler,
|
||||
admin.NewRedeemHandler,
|
||||
admin.NewSettingHandler,
|
||||
|
||||
216
backend/internal/pkg/antigravity/client.go
Normal file
216
backend/internal/pkg/antigravity/client.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TokenResponse Google OAuth token 响应
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// UserInfo Google 用户信息
|
||||
type UserInfo struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
FamilyName string `json:"family_name,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
}
|
||||
|
||||
// LoadCodeAssistRequest loadCodeAssist 请求
|
||||
type LoadCodeAssistRequest struct {
|
||||
Metadata struct {
|
||||
IDEType string `json:"ideType"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
|
||||
// LoadCodeAssistResponse loadCodeAssist 响应
|
||||
type LoadCodeAssistResponse struct {
|
||||
CloudAICompanionProject string `json:"cloudaicompanionProject"`
|
||||
}
|
||||
|
||||
// Client Antigravity API 客户端
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
proxyURL string
|
||||
}
|
||||
|
||||
func NewClient(proxyURL string) *Client {
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
if strings.TrimSpace(proxyURL) != "" {
|
||||
if proxyURLParsed, err := url.Parse(proxyURL); err == nil {
|
||||
client.Transport = &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURLParsed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Client{
|
||||
httpClient: client,
|
||||
proxyURL: proxyURL,
|
||||
}
|
||||
}
|
||||
|
||||
// ExchangeCode 用 authorization code 交换 token
|
||||
func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string) (*TokenResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("client_id", ClientID)
|
||||
params.Set("client_secret", ClientSecret)
|
||||
params.Set("code", code)
|
||||
params.Set("redirect_uri", RedirectURI)
|
||||
params.Set("grant_type", "authorization_code")
|
||||
params.Set("code_verifier", codeVerifier)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, TokenURL, strings.NewReader(params.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Token 交换请求失败: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Token 交换失败 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var tokenResp TokenResponse
|
||||
if err := json.Unmarshal(bodyBytes, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("Token 解析失败: %w", err)
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
// RefreshToken 刷新 access_token
|
||||
func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("client_id", ClientID)
|
||||
params.Set("client_secret", ClientSecret)
|
||||
params.Set("refresh_token", refreshToken)
|
||||
params.Set("grant_type", "refresh_token")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, TokenURL, strings.NewReader(params.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Token 刷新请求失败: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Token 刷新失败 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var tokenResp TokenResponse
|
||||
if err := json.Unmarshal(bodyBytes, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("Token 解析失败: %w", err)
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
// GetUserInfo 获取用户信息
|
||||
func (c *Client) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, UserInfoURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("用户信息请求失败: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("获取用户信息失败 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var userInfo UserInfo
|
||||
if err := json.Unmarshal(bodyBytes, &userInfo); err != nil {
|
||||
return nil, fmt.Errorf("用户信息解析失败: %w", err)
|
||||
}
|
||||
|
||||
return &userInfo, nil
|
||||
}
|
||||
|
||||
// LoadCodeAssist 获取 project_id
|
||||
func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadCodeAssistResponse, error) {
|
||||
reqBody := LoadCodeAssistRequest{}
|
||||
reqBody.Metadata.IDEType = "ANTIGRAVITY"
|
||||
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
url := BaseURL + "/v1internal:loadCodeAssist"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loadCodeAssist 请求失败: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("loadCodeAssist 失败 (HTTP %d): %s", resp.StatusCode, string(respBodyBytes))
|
||||
}
|
||||
|
||||
var loadResp LoadCodeAssistResponse
|
||||
if err := json.Unmarshal(respBodyBytes, &loadResp); err != nil {
|
||||
return nil, fmt.Errorf("响应解析失败: %w", err)
|
||||
}
|
||||
|
||||
return &loadResp, nil
|
||||
}
|
||||
179
backend/internal/pkg/antigravity/oauth.go
Normal file
179
backend/internal/pkg/antigravity/oauth.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Google OAuth 端点
|
||||
AuthorizeURL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
TokenURL = "https://oauth2.googleapis.com/token"
|
||||
UserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
|
||||
// Antigravity OAuth 客户端凭证
|
||||
ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||
ClientSecret = "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"
|
||||
|
||||
// 固定的 redirect_uri(用户需手动复制 code)
|
||||
RedirectURI = "http://localhost:8085/callback"
|
||||
|
||||
// OAuth scopes
|
||||
Scopes = "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"
|
||||
|
||||
// API 端点
|
||||
BaseURL = "https://cloudcode-pa.googleapis.com"
|
||||
|
||||
// User-Agent
|
||||
UserAgent = "antigravity/1.11.9 windows/amd64"
|
||||
|
||||
// Session 过期时间
|
||||
SessionTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
// OAuthSession 保存 OAuth 授权流程的临时状态
|
||||
type OAuthSession struct {
|
||||
State string `json:"state"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SessionStore OAuth session 存储
|
||||
type SessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*OAuthSession
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewSessionStore() *SessionStore {
|
||||
store := &SessionStore{
|
||||
sessions: make(map[string]*OAuthSession),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go store.cleanup()
|
||||
return store
|
||||
}
|
||||
|
||||
func (s *SessionStore) Set(sessionID string, session *OAuthSession) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[sessionID] = session
|
||||
}
|
||||
|
||||
func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
session, ok := s.sessions[sessionID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(session.CreatedAt) > SessionTTL {
|
||||
return nil, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(sessionID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, sessionID)
|
||||
}
|
||||
|
||||
func (s *SessionStore) Stop() {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
default:
|
||||
close(s.stopCh)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SessionStore) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.mu.Lock()
|
||||
for id, session := range s.sessions {
|
||||
if time.Since(session.CreatedAt) > SessionTTL {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateRandomBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func GenerateState() (string, error) {
|
||||
bytes, err := GenerateRandomBytes(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64URLEncode(bytes), nil
|
||||
}
|
||||
|
||||
func GenerateSessionID() (string, error) {
|
||||
bytes, err := GenerateRandomBytes(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func GenerateCodeVerifier() (string, error) {
|
||||
bytes, err := GenerateRandomBytes(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64URLEncode(bytes), nil
|
||||
}
|
||||
|
||||
func GenerateCodeChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
return base64URLEncode(hash[:])
|
||||
}
|
||||
|
||||
func base64URLEncode(data []byte) string {
|
||||
return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
|
||||
}
|
||||
|
||||
// BuildAuthorizationURL 构建 Google OAuth 授权 URL
|
||||
func BuildAuthorizationURL(state, codeChallenge string) string {
|
||||
params := url.Values{}
|
||||
params.Set("client_id", ClientID)
|
||||
params.Set("redirect_uri", RedirectURI)
|
||||
params.Set("response_type", "code")
|
||||
params.Set("scope", Scopes)
|
||||
params.Set("state", state)
|
||||
params.Set("code_challenge", codeChallenge)
|
||||
params.Set("code_challenge_method", "S256")
|
||||
params.Set("access_type", "offline")
|
||||
params.Set("prompt", "consent")
|
||||
params.Set("include_granted_scopes", "true")
|
||||
|
||||
return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode())
|
||||
}
|
||||
@@ -148,6 +148,14 @@ func registerGeminiOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
}
|
||||
}
|
||||
|
||||
func registerAntigravityOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
antigravity := admin.Group("/antigravity")
|
||||
{
|
||||
antigravity.POST("/oauth/auth-url", h.Admin.AntigravityOAuth.GenerateAuthURL)
|
||||
antigravity.POST("/oauth/exchange-code", h.Admin.AntigravityOAuth.ExchangeCode)
|
||||
}
|
||||
}
|
||||
|
||||
func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
proxies := admin.Group("/proxies")
|
||||
{
|
||||
|
||||
267
backend/internal/service/antigravity_oauth_service.go
Normal file
267
backend/internal/service/antigravity_oauth_service.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
|
||||
type AntigravityOAuthService struct {
|
||||
sessionStore *antigravity.SessionStore
|
||||
proxyRepo ProxyRepository
|
||||
}
|
||||
|
||||
func NewAntigravityOAuthService(proxyRepo ProxyRepository) *AntigravityOAuthService {
|
||||
return &AntigravityOAuthService{
|
||||
sessionStore: antigravity.NewSessionStore(),
|
||||
proxyRepo: proxyRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// AntigravityAuthURLResult is the result of generating an authorization URL
|
||||
type AntigravityAuthURLResult struct {
|
||||
AuthURL string `json:"auth_url"`
|
||||
SessionID string `json:"session_id"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// GenerateAuthURL 生成 Google OAuth 授权链接
|
||||
func (s *AntigravityOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64) (*AntigravityAuthURLResult, error) {
|
||||
state, err := antigravity.GenerateState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成 state 失败: %w", err)
|
||||
}
|
||||
|
||||
codeVerifier, err := antigravity.GenerateCodeVerifier()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成 code_verifier 失败: %w", err)
|
||||
}
|
||||
|
||||
sessionID, err := antigravity.GenerateSessionID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成 session_id 失败: %w", err)
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if proxyID != nil {
|
||||
proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
|
||||
if err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
session := &antigravity.OAuthSession{
|
||||
State: state,
|
||||
CodeVerifier: codeVerifier,
|
||||
ProxyURL: proxyURL,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
s.sessionStore.Set(sessionID, session)
|
||||
|
||||
codeChallenge := antigravity.GenerateCodeChallenge(codeVerifier)
|
||||
authURL := antigravity.BuildAuthorizationURL(state, codeChallenge)
|
||||
|
||||
return &AntigravityAuthURLResult{
|
||||
AuthURL: authURL,
|
||||
SessionID: sessionID,
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AntigravityExchangeCodeInput 交换 code 的输入
|
||||
type AntigravityExchangeCodeInput struct {
|
||||
SessionID string
|
||||
State string
|
||||
Code string
|
||||
ProxyID *int64
|
||||
}
|
||||
|
||||
// AntigravityTokenInfo token 信息
|
||||
type AntigravityTokenInfo struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
TokenType string `json:"token_type"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
}
|
||||
|
||||
// ExchangeCode 用 authorization code 交换 token
|
||||
func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *AntigravityExchangeCodeInput) (*AntigravityTokenInfo, error) {
|
||||
session, ok := s.sessionStore.Get(input.SessionID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("session 不存在或已过期")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(input.State) == "" || input.State != session.State {
|
||||
return nil, fmt.Errorf("state 无效")
|
||||
}
|
||||
|
||||
// 确定代理 URL
|
||||
proxyURL := session.ProxyURL
|
||||
if input.ProxyID != nil {
|
||||
proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
|
||||
if err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
client := antigravity.NewClient(proxyURL)
|
||||
|
||||
// 交换 token
|
||||
tokenResp, err := client.ExchangeCode(ctx, input.Code, session.CodeVerifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Token 交换失败: %w", err)
|
||||
}
|
||||
|
||||
// 删除 session
|
||||
s.sessionStore.Delete(input.SessionID)
|
||||
|
||||
// 计算过期时间(减去 5 分钟安全窗口)
|
||||
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
|
||||
|
||||
result := &AntigravityTokenInfo{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
ExpiresIn: tokenResp.ExpiresIn,
|
||||
ExpiresAt: expiresAt,
|
||||
TokenType: tokenResp.TokenType,
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
userInfo, err := client.GetUserInfo(ctx, tokenResp.AccessToken)
|
||||
if err != nil {
|
||||
fmt.Printf("[AntigravityOAuth] 警告: 获取用户信息失败: %v\n", err)
|
||||
} else {
|
||||
result.Email = userInfo.Email
|
||||
}
|
||||
|
||||
// 获取 project_id
|
||||
loadResp, err := client.LoadCodeAssist(ctx, tokenResp.AccessToken)
|
||||
if err != nil {
|
||||
fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败: %v\n", err)
|
||||
} else if loadResp != nil && loadResp.CloudAICompanionProject != "" {
|
||||
result.ProjectID = loadResp.CloudAICompanionProject
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RefreshToken 刷新 token
|
||||
func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*AntigravityTokenInfo, error) {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
|
||||
client := antigravity.NewClient(proxyURL)
|
||||
tokenResp, err := client.RefreshToken(ctx, refreshToken)
|
||||
if err == nil {
|
||||
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
|
||||
return &AntigravityTokenInfo{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
ExpiresIn: tokenResp.ExpiresIn,
|
||||
ExpiresAt: expiresAt,
|
||||
TokenType: tokenResp.TokenType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if isNonRetryableAntigravityOAuthError(err) {
|
||||
return nil, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Token 刷新失败 (重试后): %w", lastErr)
|
||||
}
|
||||
|
||||
func isNonRetryableAntigravityOAuthError(err error) bool {
|
||||
msg := err.Error()
|
||||
nonRetryable := []string{
|
||||
"invalid_grant",
|
||||
"invalid_client",
|
||||
"unauthorized_client",
|
||||
"access_denied",
|
||||
}
|
||||
for _, needle := range nonRetryable {
|
||||
if strings.Contains(msg, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RefreshAccountToken 刷新账户的 token
|
||||
func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*AntigravityTokenInfo, error) {
|
||||
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
|
||||
return nil, fmt.Errorf("非 Antigravity OAuth 账户")
|
||||
}
|
||||
|
||||
refreshToken := account.GetCredential("refresh_token")
|
||||
if strings.TrimSpace(refreshToken) == "" {
|
||||
return nil, fmt.Errorf("无可用的 refresh_token")
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if account.ProxyID != nil {
|
||||
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
|
||||
if err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
tokenInfo, err := s.RefreshToken(ctx, refreshToken, proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 保留原有的 project_id 和 email
|
||||
existingProjectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||
if existingProjectID != "" {
|
||||
tokenInfo.ProjectID = existingProjectID
|
||||
}
|
||||
existingEmail := strings.TrimSpace(account.GetCredential("email"))
|
||||
if existingEmail != "" {
|
||||
tokenInfo.Email = existingEmail
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
// BuildAccountCredentials 构建账户凭证
|
||||
func (s *AntigravityOAuthService) BuildAccountCredentials(tokenInfo *AntigravityTokenInfo) map[string]any {
|
||||
creds := map[string]any{
|
||||
"access_token": tokenInfo.AccessToken,
|
||||
"expires_at": strconv.FormatInt(tokenInfo.ExpiresAt, 10),
|
||||
}
|
||||
if tokenInfo.RefreshToken != "" {
|
||||
creds["refresh_token"] = tokenInfo.RefreshToken
|
||||
}
|
||||
if tokenInfo.TokenType != "" {
|
||||
creds["token_type"] = tokenInfo.TokenType
|
||||
}
|
||||
if tokenInfo.Email != "" {
|
||||
creds["email"] = tokenInfo.Email
|
||||
}
|
||||
if tokenInfo.ProjectID != "" {
|
||||
creds["project_id"] = tokenInfo.ProjectID
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
// Stop 停止服务
|
||||
func (s *AntigravityOAuthService) Stop() {
|
||||
s.sessionStore.Stop()
|
||||
}
|
||||
@@ -18,9 +18,10 @@ const (
|
||||
|
||||
// Platform constants
|
||||
const (
|
||||
PlatformAnthropic = "anthropic"
|
||||
PlatformOpenAI = "openai"
|
||||
PlatformGemini = "gemini"
|
||||
PlatformAnthropic = "anthropic"
|
||||
PlatformOpenAI = "openai"
|
||||
PlatformGemini = "gemini"
|
||||
PlatformAntigravity = "antigravity"
|
||||
)
|
||||
|
||||
// Account type constants
|
||||
|
||||
@@ -17,7 +17,7 @@ type BuildInfo struct {
|
||||
func ProvidePricingService(cfg *config.Config, remoteClient PricingRemoteClient) (*PricingService, error) {
|
||||
svc := NewPricingService(cfg, remoteClient)
|
||||
if err := svc.Initialize(); err != nil {
|
||||
// 价格服务初始化失败不应阻止启动,使用回退价格
|
||||
// Pricing service initialization failure should not block startup, use fallback prices
|
||||
println("[Service] Warning: Pricing service initialization failed:", err.Error())
|
||||
}
|
||||
return svc, nil
|
||||
@@ -81,6 +81,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewOAuthService,
|
||||
NewOpenAIOAuthService,
|
||||
NewGeminiOAuthService,
|
||||
NewAntigravityOAuthService,
|
||||
NewGeminiTokenProvider,
|
||||
NewGeminiMessagesCompatService,
|
||||
NewRateLimitService,
|
||||
|
||||
56
frontend/src/api/admin/antigravity.ts
Normal file
56
frontend/src/api/admin/antigravity.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Admin Antigravity API endpoints
|
||||
* Handles Antigravity (Google Cloud AI Companion) OAuth flows for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface AntigravityAuthUrlResponse {
|
||||
auth_url: string
|
||||
session_id: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface AntigravityAuthUrlRequest {
|
||||
proxy_id?: number
|
||||
}
|
||||
|
||||
export interface AntigravityExchangeCodeRequest {
|
||||
session_id: string
|
||||
state: string
|
||||
code: string
|
||||
proxy_id?: number
|
||||
}
|
||||
|
||||
export interface AntigravityTokenInfo {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
token_type?: string
|
||||
expires_at?: number | string
|
||||
expires_in?: number
|
||||
project_id?: string
|
||||
email?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function generateAuthUrl(
|
||||
payload: AntigravityAuthUrlRequest
|
||||
): Promise<AntigravityAuthUrlResponse> {
|
||||
const { data } = await apiClient.post<AntigravityAuthUrlResponse>(
|
||||
'/admin/antigravity/oauth/auth-url',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
payload: AntigravityExchangeCodeRequest
|
||||
): Promise<AntigravityTokenInfo> {
|
||||
const { data } = await apiClient.post<AntigravityTokenInfo>(
|
||||
'/admin/antigravity/oauth/exchange-code',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export default { generateAuthUrl, exchangeCode }
|
||||
@@ -14,6 +14,7 @@ import systemAPI from './system'
|
||||
import subscriptionsAPI from './subscriptions'
|
||||
import usageAPI from './usage'
|
||||
import geminiAPI from './gemini'
|
||||
import antigravityAPI from './antigravity'
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
@@ -29,7 +30,8 @@ export const adminAPI = {
|
||||
system: systemAPI,
|
||||
subscriptions: subscriptionsAPI,
|
||||
usage: usageAPI,
|
||||
gemini: geminiAPI
|
||||
gemini: geminiAPI,
|
||||
antigravity: antigravityAPI
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -43,7 +45,8 @@ export {
|
||||
systemAPI,
|
||||
subscriptionsAPI,
|
||||
usageAPI,
|
||||
geminiAPI
|
||||
geminiAPI,
|
||||
antigravityAPI
|
||||
}
|
||||
|
||||
export default adminAPI
|
||||
|
||||
@@ -125,6 +125,31 @@
|
||||
</svg>
|
||||
Gemini
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="form.platform = 'antigravity'"
|
||||
:class="[
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
|
||||
form.platform === 'antigravity'
|
||||
? 'bg-white text-purple-600 shadow-sm dark:bg-dark-600 dark:text-purple-400'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"
|
||||
/>
|
||||
</svg>
|
||||
Antigravity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -477,6 +502,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Type Selection (Antigravity - OAuth only) -->
|
||||
<div v-if="form.platform === 'antigravity'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="mt-2">
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border-2 border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20"
|
||||
>
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-purple-500 text-white">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.antigravityOauth') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Method (only for Anthropic OAuth-based type) -->
|
||||
<div v-if="form.platform === 'anthropic' && isOAuthFlow">
|
||||
<label class="input-label">{{ t('admin.accounts.addMethod') }}</label>
|
||||
@@ -1072,6 +1127,7 @@ import {
|
||||
} from '@/composables/useAccountOAuth'
|
||||
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
||||
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
||||
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
|
||||
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
@@ -1094,6 +1150,7 @@ const { t } = useI18n()
|
||||
const oauthStepTitle = computed(() => {
|
||||
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title')
|
||||
if (form.platform === 'gemini') return t('admin.accounts.oauth.gemini.title')
|
||||
if (form.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.title')
|
||||
return t('admin.accounts.oauth.title')
|
||||
})
|
||||
|
||||
@@ -1115,29 +1172,34 @@ const appStore = useAppStore()
|
||||
const oauth = useAccountOAuth() // For Anthropic OAuth
|
||||
const openaiOAuth = useOpenAIOAuth() // For OpenAI OAuth
|
||||
const geminiOAuth = useGeminiOAuth() // For Gemini OAuth
|
||||
const antigravityOAuth = useAntigravityOAuth() // For Antigravity OAuth
|
||||
|
||||
// Computed: current OAuth state for template binding
|
||||
const currentAuthUrl = computed(() => {
|
||||
if (form.platform === 'openai') return openaiOAuth.authUrl.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.authUrl.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.authUrl.value
|
||||
return oauth.authUrl.value
|
||||
})
|
||||
|
||||
const currentSessionId = computed(() => {
|
||||
if (form.platform === 'openai') return openaiOAuth.sessionId.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.sessionId.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.sessionId.value
|
||||
return oauth.sessionId.value
|
||||
})
|
||||
|
||||
const currentOAuthLoading = computed(() => {
|
||||
if (form.platform === 'openai') return openaiOAuth.loading.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.loading.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.loading.value
|
||||
return oauth.loading.value
|
||||
})
|
||||
|
||||
const currentOAuthError = computed(() => {
|
||||
if (form.platform === 'openai') return openaiOAuth.error.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.error.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.error.value
|
||||
return oauth.error.value
|
||||
})
|
||||
|
||||
@@ -1366,6 +1428,9 @@ const canExchangeCode = computed(() => {
|
||||
if (form.platform === 'gemini') {
|
||||
return authCode.trim() && geminiOAuth.sessionId.value && !geminiOAuth.loading.value
|
||||
}
|
||||
if (form.platform === 'antigravity') {
|
||||
return authCode.trim() && antigravityOAuth.sessionId.value && !antigravityOAuth.loading.value
|
||||
}
|
||||
return authCode.trim() && oauth.sessionId.value && !oauth.loading.value
|
||||
})
|
||||
|
||||
@@ -1410,10 +1475,15 @@ watch(
|
||||
if (newPlatform !== 'anthropic') {
|
||||
interceptWarmupRequests.value = false
|
||||
}
|
||||
// Antigravity only supports OAuth
|
||||
if (newPlatform === 'antigravity') {
|
||||
accountCategory.value = 'oauth-based'
|
||||
}
|
||||
// Reset OAuth states
|
||||
oauth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1542,6 +1612,7 @@ const resetForm = () => {
|
||||
oauth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
}
|
||||
|
||||
@@ -1620,6 +1691,7 @@ const goBackToBasicInfo = () => {
|
||||
oauth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
}
|
||||
|
||||
@@ -1628,114 +1700,133 @@ const handleGenerateUrl = async () => {
|
||||
await openaiOAuth.generateAuthUrl(form.proxy_id)
|
||||
} else if (form.platform === 'gemini') {
|
||||
await geminiOAuth.generateAuthUrl(form.proxy_id, oauthFlowRef.value?.projectId, geminiOAuthType.value)
|
||||
} else if (form.platform === 'antigravity') {
|
||||
await antigravityOAuth.generateAuthUrl(form.proxy_id)
|
||||
} else {
|
||||
await oauth.generateAuthUrl(addMethod.value, form.proxy_id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExchangeCode = async () => {
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
// Create account and handle success/failure
|
||||
const createAccountAndFinish = async (
|
||||
platform: AccountPlatform,
|
||||
type: AccountType,
|
||||
credentials: Record<string, unknown>,
|
||||
extra?: Record<string, string>
|
||||
) => {
|
||||
await adminAPI.accounts.create({
|
||||
name: form.name,
|
||||
platform,
|
||||
type,
|
||||
credentials,
|
||||
extra,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
priority: form.priority,
|
||||
group_ids: form.group_ids
|
||||
})
|
||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
||||
emit('created')
|
||||
handleClose()
|
||||
}
|
||||
|
||||
// For OpenAI
|
||||
if (form.platform === 'openai') {
|
||||
if (!authCode.trim() || !openaiOAuth.sessionId.value) return
|
||||
// OpenAI OAuth 授权码兑换
|
||||
const handleOpenAIExchange = async (authCode: string) => {
|
||||
if (!authCode.trim() || !openaiOAuth.sessionId.value) return
|
||||
|
||||
openaiOAuth.loading.value = true
|
||||
openaiOAuth.error.value = ''
|
||||
openaiOAuth.loading.value = true
|
||||
openaiOAuth.error.value = ''
|
||||
|
||||
try {
|
||||
const tokenInfo = await openaiOAuth.exchangeAuthCode(
|
||||
authCode.trim(),
|
||||
openaiOAuth.sessionId.value,
|
||||
form.proxy_id
|
||||
)
|
||||
try {
|
||||
const tokenInfo = await openaiOAuth.exchangeAuthCode(
|
||||
authCode.trim(),
|
||||
openaiOAuth.sessionId.value,
|
||||
form.proxy_id
|
||||
)
|
||||
if (!tokenInfo) return
|
||||
|
||||
if (!tokenInfo) {
|
||||
return // Error already handled by composable
|
||||
}
|
||||
|
||||
const credentials = openaiOAuth.buildCredentials(tokenInfo)
|
||||
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
|
||||
|
||||
// Note: intercept_warmup_requests is Anthropic-only, not applicable to OpenAI
|
||||
|
||||
await adminAPI.accounts.create({
|
||||
name: form.name,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
credentials,
|
||||
extra,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
priority: form.priority,
|
||||
group_ids: form.group_ids
|
||||
})
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
||||
emit('created')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(openaiOAuth.error.value)
|
||||
} finally {
|
||||
openaiOAuth.loading.value = false
|
||||
}
|
||||
return
|
||||
const credentials = openaiOAuth.buildCredentials(tokenInfo)
|
||||
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
|
||||
await createAccountAndFinish('openai', 'oauth', credentials, extra)
|
||||
} catch (error: any) {
|
||||
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(openaiOAuth.error.value)
|
||||
} finally {
|
||||
openaiOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// For Gemini
|
||||
if (form.platform === 'gemini') {
|
||||
if (!authCode.trim() || !geminiOAuth.sessionId.value) return
|
||||
// Gemini OAuth 授权码兑换
|
||||
const handleGeminiExchange = async (authCode: string) => {
|
||||
if (!authCode.trim() || !geminiOAuth.sessionId.value) return
|
||||
|
||||
geminiOAuth.loading.value = true
|
||||
geminiOAuth.error.value = ''
|
||||
geminiOAuth.loading.value = true
|
||||
geminiOAuth.error.value = ''
|
||||
|
||||
try {
|
||||
const stateFromInput = oauthFlowRef.value?.oauthState || ''
|
||||
const stateToUse = stateFromInput || geminiOAuth.state.value
|
||||
if (!stateToUse) {
|
||||
geminiOAuth.error.value = t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(geminiOAuth.error.value)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenInfo = await geminiOAuth.exchangeAuthCode({
|
||||
code: authCode.trim(),
|
||||
sessionId: geminiOAuth.sessionId.value,
|
||||
state: stateToUse,
|
||||
proxyId: form.proxy_id,
|
||||
oauthType: geminiOAuthType.value
|
||||
})
|
||||
if (!tokenInfo) return
|
||||
|
||||
const credentials = geminiOAuth.buildCredentials(tokenInfo)
|
||||
|
||||
// Note: intercept_warmup_requests is Anthropic-only, not applicable to Gemini
|
||||
|
||||
await adminAPI.accounts.create({
|
||||
name: form.name,
|
||||
platform: 'gemini',
|
||||
type: 'oauth',
|
||||
credentials,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
priority: form.priority,
|
||||
group_ids: form.group_ids
|
||||
})
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
||||
emit('created')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
try {
|
||||
const stateFromInput = oauthFlowRef.value?.oauthState || ''
|
||||
const stateToUse = stateFromInput || geminiOAuth.state.value
|
||||
if (!stateToUse) {
|
||||
geminiOAuth.error.value = t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(geminiOAuth.error.value)
|
||||
} finally {
|
||||
geminiOAuth.loading.value = false
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For Anthropic
|
||||
const tokenInfo = await geminiOAuth.exchangeAuthCode({
|
||||
code: authCode.trim(),
|
||||
sessionId: geminiOAuth.sessionId.value,
|
||||
state: stateToUse,
|
||||
proxyId: form.proxy_id,
|
||||
oauthType: geminiOAuthType.value
|
||||
})
|
||||
if (!tokenInfo) return
|
||||
|
||||
const credentials = geminiOAuth.buildCredentials(tokenInfo)
|
||||
await createAccountAndFinish('gemini', 'oauth', credentials)
|
||||
} catch (error: any) {
|
||||
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(geminiOAuth.error.value)
|
||||
} finally {
|
||||
geminiOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Antigravity OAuth 授权码兑换
|
||||
const handleAntigravityExchange = async (authCode: string) => {
|
||||
if (!authCode.trim() || !antigravityOAuth.sessionId.value) return
|
||||
|
||||
antigravityOAuth.loading.value = true
|
||||
antigravityOAuth.error.value = ''
|
||||
|
||||
try {
|
||||
const stateFromInput = oauthFlowRef.value?.oauthState || ''
|
||||
const stateToUse = stateFromInput || antigravityOAuth.state.value
|
||||
if (!stateToUse) {
|
||||
antigravityOAuth.error.value = t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(antigravityOAuth.error.value)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenInfo = await antigravityOAuth.exchangeAuthCode({
|
||||
code: authCode.trim(),
|
||||
sessionId: antigravityOAuth.sessionId.value,
|
||||
state: stateToUse,
|
||||
proxyId: form.proxy_id
|
||||
})
|
||||
if (!tokenInfo) return
|
||||
|
||||
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
|
||||
await createAccountAndFinish('antigravity', 'oauth', credentials)
|
||||
} catch (error: any) {
|
||||
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(antigravityOAuth.error.value)
|
||||
} finally {
|
||||
antigravityOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic OAuth 授权码兑换
|
||||
const handleAnthropicExchange = async (authCode: string) => {
|
||||
if (!authCode.trim() || !oauth.sessionId.value) return
|
||||
|
||||
oauth.loading.value = true
|
||||
@@ -1755,28 +1846,11 @@ const handleExchangeCode = async () => {
|
||||
})
|
||||
|
||||
const extra = oauth.buildExtraInfo(tokenInfo)
|
||||
|
||||
// Merge interceptWarmupRequests into credentials
|
||||
const credentials = {
|
||||
...tokenInfo,
|
||||
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
|
||||
}
|
||||
|
||||
await adminAPI.accounts.create({
|
||||
name: form.name,
|
||||
platform: form.platform,
|
||||
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
|
||||
credentials,
|
||||
extra,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
priority: form.priority,
|
||||
group_ids: form.group_ids
|
||||
})
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
||||
emit('created')
|
||||
handleClose()
|
||||
await createAccountAndFinish(form.platform, addMethod.value as AccountType, credentials, extra)
|
||||
} catch (error: any) {
|
||||
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(oauth.error.value)
|
||||
@@ -1785,6 +1859,22 @@ const handleExchangeCode = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 主入口:根据平台路由到对应处理函数
|
||||
const handleExchangeCode = async () => {
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
|
||||
switch (form.platform) {
|
||||
case 'openai':
|
||||
return handleOpenAIExchange(authCode)
|
||||
case 'gemini':
|
||||
return handleGeminiExchange(authCode)
|
||||
case 'antigravity':
|
||||
return handleAntigravityExchange(authCode)
|
||||
default:
|
||||
return handleAnthropicExchange(authCode)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCookieAuth = async (sessionKey: string) => {
|
||||
oauth.loading.value = true
|
||||
oauth.error.value = ''
|
||||
|
||||
@@ -527,7 +527,7 @@ interface Props {
|
||||
allowMultiple?: boolean
|
||||
methodLabel?: string
|
||||
showCookieOption?: boolean // Whether to show cookie auto-auth option
|
||||
platform?: 'anthropic' | 'openai' | 'gemini' // Platform type for different UI/text
|
||||
platform?: 'anthropic' | 'openai' | 'gemini' | 'antigravity' // Platform type for different UI/text
|
||||
showProjectId?: boolean // New prop to control project ID visibility
|
||||
}
|
||||
|
||||
@@ -560,6 +560,7 @@ const isOpenAI = computed(() => props.platform === 'openai')
|
||||
const getOAuthKey = (key: string) => {
|
||||
if (props.platform === 'openai') return `admin.accounts.oauth.openai.${key}`
|
||||
if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}`
|
||||
if (props.platform === 'antigravity') return `admin.accounts.oauth.antigravity.${key}`
|
||||
return `admin.accounts.oauth.${key}`
|
||||
}
|
||||
|
||||
@@ -575,9 +576,11 @@ const oauthAuthCodeDesc = computed(() => t(getOAuthKey('authCodeDesc')))
|
||||
const oauthAuthCode = computed(() => t(getOAuthKey('authCode')))
|
||||
const oauthAuthCodePlaceholder = computed(() => t(getOAuthKey('authCodePlaceholder')))
|
||||
const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint')))
|
||||
const oauthImportantNotice = computed(() =>
|
||||
props.platform === 'openai' ? t('admin.accounts.oauth.openai.importantNotice') : ''
|
||||
)
|
||||
const oauthImportantNotice = computed(() => {
|
||||
if (props.platform === 'openai') return t('admin.accounts.oauth.openai.importantNotice')
|
||||
if (props.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.importantNotice')
|
||||
return ''
|
||||
})
|
||||
|
||||
// Local state
|
||||
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
|
||||
@@ -603,10 +606,10 @@ watch(inputMethod, (newVal) => {
|
||||
emit('update:inputMethod', newVal)
|
||||
})
|
||||
|
||||
// Auto-extract code from OpenAI callback URL
|
||||
// e.g., http://localhost:1455/auth/callback?code=ac_xxx...&scope=...&state=...
|
||||
// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity)
|
||||
// e.g., http://localhost:8085/callback?code=xxx...&state=...
|
||||
watch(authCodeInput, (newVal) => {
|
||||
if (props.platform !== 'openai' && props.platform !== 'gemini') return
|
||||
if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity') return
|
||||
|
||||
const trimmed = newVal.trim()
|
||||
// Check if it looks like a URL with code parameter
|
||||
@@ -616,7 +619,7 @@ watch(authCodeInput, (newVal) => {
|
||||
const url = new URL(trimmed)
|
||||
const code = url.searchParams.get('code')
|
||||
const stateParam = url.searchParams.get('state')
|
||||
if (props.platform === 'gemini' && stateParam) {
|
||||
if ((props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
|
||||
oauthState.value = stateParam
|
||||
}
|
||||
if (code && code !== trimmed) {
|
||||
@@ -627,7 +630,7 @@ watch(authCodeInput, (newVal) => {
|
||||
// If URL parsing fails, try regex extraction
|
||||
const match = trimmed.match(/[?&]code=([^&]+)/)
|
||||
const stateMatch = trimmed.match(/[?&]state=([^&]+)/)
|
||||
if (props.platform === 'gemini' && stateMatch && stateMatch[1]) {
|
||||
if ((props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
|
||||
oauthState.value = stateMatch[1]
|
||||
}
|
||||
if (match && match[1] && match[1] !== trimmed) {
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<svg v-else-if="platform === 'gemini'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2l1.89 7.2L21 12l-7.11 2.8L12 22l-1.89-7.2L3 12l7.11-2.8L12 2z" />
|
||||
</svg>
|
||||
<!-- Antigravity logo (cloud) -->
|
||||
<svg v-else-if="platform === 'antigravity'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
|
||||
</svg>
|
||||
<!-- Fallback: generic platform icon -->
|
||||
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
||||
@@ -72,6 +72,7 @@ const props = defineProps<Props>()
|
||||
const platformLabel = computed(() => {
|
||||
if (props.platform === 'anthropic') return 'Anthropic'
|
||||
if (props.platform === 'openai') return 'OpenAI'
|
||||
if (props.platform === 'antigravity') return 'Antigravity'
|
||||
return 'Gemini'
|
||||
})
|
||||
|
||||
@@ -95,6 +96,9 @@ const platformClass = computed(() => {
|
||||
if (props.platform === 'openai') {
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
}
|
||||
if (props.platform === 'antigravity') {
|
||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
})
|
||||
|
||||
@@ -105,6 +109,9 @@ const typeClass = computed(() => {
|
||||
if (props.platform === 'openai') {
|
||||
return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
}
|
||||
if (props.platform === 'antigravity') {
|
||||
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}
|
||||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
})
|
||||
</script>
|
||||
|
||||
115
frontend/src/composables/useAntigravityOAuth.ts
Normal file
115
frontend/src/composables/useAntigravityOAuth.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { AntigravityTokenInfo } from '@/api/admin/antigravity'
|
||||
|
||||
export function useAntigravityOAuth() {
|
||||
const appStore = useAppStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const authUrl = ref('')
|
||||
const sessionId = ref('')
|
||||
const state = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const resetState = () => {
|
||||
authUrl.value = ''
|
||||
sessionId.value = ''
|
||||
state.value = ''
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const generateAuthUrl = async (proxyId: number | null | undefined): Promise<boolean> => {
|
||||
loading.value = true
|
||||
authUrl.value = ''
|
||||
sessionId.value = ''
|
||||
state.value = ''
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (proxyId) payload.proxy_id = proxyId
|
||||
|
||||
const response = await adminAPI.antigravity.generateAuthUrl(payload as any)
|
||||
authUrl.value = response.auth_url
|
||||
sessionId.value = response.session_id
|
||||
state.value = response.state
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value =
|
||||
err.response?.data?.detail || t('admin.accounts.oauth.antigravity.failedToGenerateUrl')
|
||||
appStore.showError(error.value)
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const exchangeAuthCode = async (params: {
|
||||
code: string
|
||||
sessionId: string
|
||||
state: string
|
||||
proxyId?: number | null
|
||||
}): Promise<AntigravityTokenInfo | null> => {
|
||||
const code = params.code?.trim()
|
||||
if (!code || !params.sessionId || !params.state) {
|
||||
error.value = t('admin.accounts.oauth.antigravity.missingExchangeParams')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
session_id: params.sessionId,
|
||||
state: params.state,
|
||||
code
|
||||
}
|
||||
if (params.proxyId) payload.proxy_id = params.proxyId
|
||||
|
||||
const tokenInfo = await adminAPI.antigravity.exchangeCode(payload as any)
|
||||
return tokenInfo as AntigravityTokenInfo
|
||||
} catch (err: any) {
|
||||
error.value =
|
||||
err.response?.data?.detail || t('admin.accounts.oauth.antigravity.failedToExchangeCode')
|
||||
appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const buildCredentials = (tokenInfo: AntigravityTokenInfo): Record<string, unknown> => {
|
||||
let expiresAt: string | undefined
|
||||
if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) {
|
||||
expiresAt = Math.floor(tokenInfo.expires_at).toString()
|
||||
} else if (typeof tokenInfo.expires_at === 'string' && tokenInfo.expires_at.trim()) {
|
||||
expiresAt = tokenInfo.expires_at.trim()
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: tokenInfo.access_token,
|
||||
refresh_token: tokenInfo.refresh_token,
|
||||
token_type: tokenInfo.token_type,
|
||||
expires_at: expiresAt,
|
||||
project_id: tokenInfo.project_id,
|
||||
email: tokenInfo.email
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authUrl,
|
||||
sessionId,
|
||||
state,
|
||||
loading,
|
||||
error,
|
||||
resetState,
|
||||
generateAuthUrl,
|
||||
exchangeAuthCode,
|
||||
buildCredentials
|
||||
}
|
||||
}
|
||||
@@ -820,14 +820,16 @@ export default {
|
||||
anthropic: 'Anthropic',
|
||||
claude: 'Claude',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini'
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
},
|
||||
types: {
|
||||
oauth: 'OAuth',
|
||||
chatgptOauth: 'ChatGPT OAuth',
|
||||
responsesApi: 'Responses API',
|
||||
googleOauth: 'Google OAuth',
|
||||
codeAssist: 'Code Assist'
|
||||
codeAssist: 'Code Assist',
|
||||
antigravityOauth: 'Antigravity OAuth'
|
||||
},
|
||||
columns: {
|
||||
name: 'Name',
|
||||
@@ -1056,7 +1058,28 @@ export default {
|
||||
'AI Studio OAuth is not configured: set GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and add Redirect URI: http://localhost:1455/auth/callback (Consent screen scopes must include https://www.googleapis.com/auth/generative-language.retriever)',
|
||||
aiStudioNotConfigured:
|
||||
'AI Studio OAuth is not configured: set GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and add Redirect URI: http://localhost:1455/auth/callback'
|
||||
}
|
||||
},
|
||||
// Antigravity specific
|
||||
antigravity: {
|
||||
title: 'Antigravity Account Authorization',
|
||||
followSteps: 'Follow these steps to authorize your Antigravity account:',
|
||||
step1GenerateUrl: 'Generate the authorization URL',
|
||||
generateAuthUrl: 'Generate Auth URL',
|
||||
step2OpenUrl: 'Open the URL in your browser and complete authorization',
|
||||
openUrlDesc: 'Open the authorization URL in a new tab, log in to your Google account and authorize.',
|
||||
importantNotice:
|
||||
'<strong>Important:</strong> The page may take a while to load after authorization. Please wait patiently. When the browser address bar shows <code>http://localhost...</code>, authorization is complete.',
|
||||
step3EnterCode: 'Enter Authorization URL or Code',
|
||||
authCodeDesc:
|
||||
'After authorization, when the page URL becomes <code>http://localhost:xxx/auth/callback?code=...</code>:',
|
||||
authCode: 'Authorization URL or Code',
|
||||
authCodePlaceholder:
|
||||
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
|
||||
authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect',
|
||||
failedToGenerateUrl: 'Failed to generate Antigravity auth URL',
|
||||
missingExchangeParams: 'Missing code, session ID, or state',
|
||||
failedToExchangeCode: 'Failed to exchange Antigravity auth code'
|
||||
}
|
||||
},
|
||||
// Gemini specific (platform-wide)
|
||||
gemini: {
|
||||
@@ -1070,6 +1093,7 @@ export default {
|
||||
claudeCodeAccount: 'Claude Code Account',
|
||||
openaiAccount: 'OpenAI Account',
|
||||
geminiAccount: 'Gemini Account',
|
||||
antigravityAccount: 'Antigravity Account',
|
||||
inputMethod: 'Input Method',
|
||||
reAuthorizedSuccess: 'Account re-authorized successfully',
|
||||
// Test Modal
|
||||
|
||||
@@ -940,7 +940,8 @@ export default {
|
||||
claude: 'Claude',
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
gemini: 'Gemini'
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
},
|
||||
types: {
|
||||
oauth: 'OAuth',
|
||||
@@ -948,6 +949,7 @@ export default {
|
||||
responsesApi: 'Responses API',
|
||||
googleOauth: 'Google OAuth',
|
||||
codeAssist: 'Code Assist',
|
||||
antigravityOauth: 'Antigravity OAuth',
|
||||
api_key: 'API Key',
|
||||
cookie: 'Cookie'
|
||||
},
|
||||
@@ -1178,7 +1180,28 @@ export default {
|
||||
aiStudioNotConfiguredShort: '未配置',
|
||||
aiStudioNotConfiguredTip: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback(Consent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever)',
|
||||
aiStudioNotConfigured: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback'
|
||||
}
|
||||
},
|
||||
// Antigravity specific
|
||||
antigravity: {
|
||||
title: 'Antigravity 账户授权',
|
||||
followSteps: '请按照以下步骤完成 Antigravity 账户的授权:',
|
||||
step1GenerateUrl: '生成授权链接',
|
||||
generateAuthUrl: '生成授权链接',
|
||||
step2OpenUrl: '在浏览器中打开链接并完成授权',
|
||||
openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。',
|
||||
importantNotice:
|
||||
'<strong>重要提示:</strong>授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 <code>http://localhost...</code> 开头时,表示授权已完成。',
|
||||
step3EnterCode: '输入授权链接或 Code',
|
||||
authCodeDesc:
|
||||
'授权完成后,当页面地址变为 <code>http://localhost:xxx/auth/callback?code=...</code> 时:',
|
||||
authCode: '授权链接或 Code',
|
||||
authCodePlaceholder:
|
||||
'方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值',
|
||||
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
|
||||
failedToGenerateUrl: '生成 Antigravity 授权链接失败',
|
||||
missingExchangeParams: '缺少 code / session_id / state',
|
||||
failedToExchangeCode: 'Antigravity 授权码兑换失败'
|
||||
}
|
||||
},
|
||||
// Gemini specific (platform-wide)
|
||||
gemini: {
|
||||
@@ -1191,6 +1214,7 @@ export default {
|
||||
claudeCodeAccount: 'Claude Code 账号',
|
||||
openaiAccount: 'OpenAI 账号',
|
||||
geminiAccount: 'Gemini 账号',
|
||||
antigravityAccount: 'Antigravity 账号',
|
||||
inputMethod: '输入方式',
|
||||
reAuthorizedSuccess: '账号重新授权成功',
|
||||
// Test Modal
|
||||
|
||||
@@ -220,7 +220,7 @@ export interface PaginationConfig {
|
||||
|
||||
// ==================== API Key & Group Types ====================
|
||||
|
||||
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini'
|
||||
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
||||
|
||||
export type SubscriptionType = 'standard' | 'subscription'
|
||||
|
||||
@@ -256,7 +256,7 @@ export interface ApiKey {
|
||||
export interface CreateApiKeyRequest {
|
||||
name: string
|
||||
group_id?: number | null
|
||||
custom_key?: string // 可选的自定义API Key
|
||||
custom_key?: string // Optional custom API Key
|
||||
}
|
||||
|
||||
export interface UpdateApiKeyRequest {
|
||||
@@ -284,7 +284,7 @@ export interface UpdateGroupRequest {
|
||||
|
||||
// ==================== Account & Proxy Types ====================
|
||||
|
||||
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini'
|
||||
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
||||
export type AccountType = 'oauth' | 'setup-token' | 'apikey'
|
||||
export type OAuthAddMethod = 'oauth' | 'setup-token'
|
||||
export type ProxyProtocol = 'http' | 'https' | 'socks5'
|
||||
|
||||
@@ -594,7 +594,8 @@ const platformOptions = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.allPlatforms') },
|
||||
{ value: 'anthropic', label: t('admin.accounts.platforms.anthropic') },
|
||||
{ value: 'openai', label: t('admin.accounts.platforms.openai') },
|
||||
{ value: 'gemini', label: t('admin.accounts.platforms.gemini') }
|
||||
{ value: 'gemini', label: t('admin.accounts.platforms.gemini') },
|
||||
{ value: 'antigravity', label: t('admin.accounts.platforms.antigravity') }
|
||||
])
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
|
||||
Reference in New Issue
Block a user