feat(gemini): 添加 Google One 存储空间推断 Tier 功能

## 功能概述
通过 Google Drive API 获取存储空间配额来推断 Google One 订阅等级,并优化统一的配额显示系统。

## 后端改动
- 新增 Drive API 客户端 (drive_client.go)
  - 支持代理和指数退避重试
  - 处理 403/429 错误
- 添加 Tier 推断逻辑 (inferGoogleOneTier)
  - 支持 6 种 tier 类型:AI_PREMIUM, GOOGLE_ONE_STANDARD, GOOGLE_ONE_BASIC, FREE, GOOGLE_ONE_UNKNOWN, GOOGLE_ONE_UNLIMITED
- 集成到 OAuth 流程
  - ExchangeCode: 授权时自动获取 tier
  - RefreshAccountToken: Token 刷新时更新 tier (24小时缓存)
- 新增管理 API 端点
  - POST /api/v1/admin/accounts/:id/refresh-tier (单个账号刷新)
  - POST /api/v1/admin/accounts/batch-refresh-tier (批量刷新)

## 前端改动
- 更新 AccountQuotaInfo.vue
  - 添加 Google One tier 标签映射
  - 添加 tier 颜色样式 (紫色/蓝色/绿色/灰色/琥珀色)
- 更新 AccountUsageCell.vue
  - 添加 Google One tier 显示逻辑
  - 根据 oauth_type 区分显示方式
- 添加国际化翻译 (en.ts, zh.ts)
  - aiPremium, standard, basic, free, personal, unlimited

## Tier 推断规则
- >= 2TB: AI Premium
- >= 200GB: Google One Standard
- >= 100GB: Google One Basic
- >= 15GB: Free
- > 100TB: Unlimited (G Suite legacy)
- 其他/失败: Unknown (显示为 Personal)

## 优雅降级
- Drive API 失败时使用 GOOGLE_ONE_UNKNOWN
- 不阻断 OAuth 流程
- 24小时缓存避免频繁调用

## 测试
-  后端编译成功
-  前端构建成功
-  所有代码符合现有规范
This commit is contained in:
ianshaw
2025-12-31 21:45:24 -08:00
parent 4f13c8de0d
commit 7df914af06
11 changed files with 691 additions and 110 deletions

View File

@@ -3,6 +3,7 @@ package admin
import ( import (
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
@@ -989,3 +990,167 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
response.Success(c, models) response.Success(c, models)
} }
// RefreshTier handles refreshing Google One tier for a single account
// POST /api/v1/admin/accounts/:id/refresh-tier
func (h *AccountHandler) RefreshTier(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
if err != nil {
response.NotFound(c, "Account not found")
return
}
if account.Credentials == nil || account.Credentials["oauth_type"] != "google_one" {
response.BadRequest(c, "Account is not a google_one OAuth account")
return
}
accessToken, ok := account.Credentials["access_token"].(string)
if !ok || accessToken == "" {
response.BadRequest(c, "Missing access_token in credentials")
return
}
var proxyURL string
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
tierID, storageInfo, err := h.geminiOAuthService.FetchGoogleOneTier(c.Request.Context(), accessToken, proxyURL)
if account.Extra == nil {
account.Extra = make(map[string]any)
}
if storageInfo != nil {
account.Extra["drive_storage_limit"] = storageInfo.Limit
account.Extra["drive_storage_usage"] = storageInfo.Usage
account.Extra["drive_tier_updated_at"] = timezone.Now().Format(time.RFC3339)
}
account.Credentials["tier_id"] = tierID
_, updateErr := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
Credentials: account.Credentials,
Extra: account.Extra,
})
if updateErr != nil {
response.ErrorFrom(c, updateErr)
return
}
response.Success(c, gin.H{
"tier_id": tierID,
"drive_storage_limit": account.Extra["drive_storage_limit"],
"drive_storage_usage": account.Extra["drive_storage_usage"],
"updated_at": account.Extra["drive_tier_updated_at"],
})
}
// BatchRefreshTierRequest represents batch tier refresh request
type BatchRefreshTierRequest struct {
AccountIDs []int64 `json:"account_ids"`
}
// BatchRefreshTier handles batch refreshing Google One tier
// POST /api/v1/admin/accounts/batch-refresh-tier
func (h *AccountHandler) BatchRefreshTier(c *gin.Context) {
var req BatchRefreshTierRequest
if err := c.ShouldBindJSON(&req); err != nil {
req = BatchRefreshTierRequest{}
}
ctx := c.Request.Context()
var accounts []service.Account
if len(req.AccountIDs) == 0 {
allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "")
if err != nil {
response.ErrorFrom(c, err)
return
}
for _, acc := range allAccounts {
if acc.Credentials != nil && acc.Credentials["oauth_type"] == "google_one" {
accounts = append(accounts, acc)
}
}
} else {
for _, id := range req.AccountIDs {
acc, err := h.adminService.GetAccount(ctx, id)
if err != nil {
continue
}
if acc.Credentials != nil && acc.Credentials["oauth_type"] == "google_one" {
accounts = append(accounts, *acc)
}
}
}
total := len(accounts)
success := 0
failed := 0
errors := []gin.H{}
for _, account := range accounts {
accessToken, ok := account.Credentials["access_token"].(string)
if !ok || accessToken == "" {
failed++
errors = append(errors, gin.H{
"account_id": account.ID,
"error": "missing access_token",
})
continue
}
var proxyURL string
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
tierID, storageInfo, err := h.geminiOAuthService.FetchGoogleOneTier(ctx, accessToken, proxyURL)
if err != nil {
failed++
errors = append(errors, gin.H{
"account_id": account.ID,
"error": err.Error(),
})
continue
}
if account.Extra == nil {
account.Extra = make(map[string]any)
}
if storageInfo != nil {
account.Extra["drive_storage_limit"] = storageInfo.Limit
account.Extra["drive_storage_usage"] = storageInfo.Usage
account.Extra["drive_tier_updated_at"] = timezone.Now().Format(time.RFC3339)
}
account.Credentials["tier_id"] = tierID
_, updateErr := h.adminService.UpdateAccount(ctx, account.ID, &service.UpdateAccountInput{
Credentials: account.Credentials,
Extra: account.Extra,
})
if updateErr != nil {
failed++
errors = append(errors, gin.H{
"account_id": account.ID,
"error": updateErr.Error(),
})
continue
}
success++
}
response.Success(c, gin.H{
"total": total,
"success": success,
"failed": failed,
"errors": errors,
})
}

View File

@@ -46,8 +46,8 @@ func (h *GeminiOAuthHandler) GenerateAuthURL(c *gin.Context) {
if oauthType == "" { if oauthType == "" {
oauthType = "code_assist" oauthType = "code_assist"
} }
if oauthType != "code_assist" && oauthType != "ai_studio" { if oauthType != "code_assist" && oauthType != "google_one" && oauthType != "ai_studio" {
response.BadRequest(c, "Invalid oauth_type: must be 'code_assist' or 'ai_studio'") response.BadRequest(c, "Invalid oauth_type: must be 'code_assist', 'google_one', or 'ai_studio'")
return return
} }
@@ -92,8 +92,8 @@ func (h *GeminiOAuthHandler) ExchangeCode(c *gin.Context) {
if oauthType == "" { if oauthType == "" {
oauthType = "code_assist" oauthType = "code_assist"
} }
if oauthType != "code_assist" && oauthType != "ai_studio" { if oauthType != "code_assist" && oauthType != "google_one" && oauthType != "ai_studio" {
response.BadRequest(c, "Invalid oauth_type: must be 'code_assist' or 'ai_studio'") response.BadRequest(c, "Invalid oauth_type: must be 'code_assist', 'google_one', or 'ai_studio'")
return return
} }

View File

@@ -0,0 +1,113 @@
package geminicli
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
)
// DriveStorageInfo represents Google Drive storage quota information
type DriveStorageInfo struct {
Limit int64 `json:"limit"` // Storage limit in bytes
Usage int64 `json:"usage"` // Current usage in bytes
}
// DriveClient interface for Google Drive API operations
type DriveClient interface {
GetStorageQuota(ctx context.Context, accessToken, proxyURL string) (*DriveStorageInfo, error)
}
type driveClient struct {
httpClient *http.Client
}
// NewDriveClient creates a new Drive API client
func NewDriveClient() DriveClient {
return &driveClient{
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// GetStorageQuota fetches storage quota from Google Drive API
func (c *driveClient) GetStorageQuota(ctx context.Context, accessToken, proxyURL string) (*DriveStorageInfo, error) {
const driveAPIURL = "https://www.googleapis.com/drive/v3/about?fields=storageQuota"
req, err := http.NewRequestWithContext(ctx, "GET", driveAPIURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
// Get HTTP client with proxy support
client, err := httpclient.GetClient(httpclient.Options{
ProxyURL: proxyURL,
Timeout: 10 * time.Second,
})
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
}
// Retry logic with exponential backoff for rate limits
var resp *http.Response
maxRetries := 3
for attempt := 0; attempt < maxRetries; attempt++ {
resp, err = client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
// Success
if resp.StatusCode == http.StatusOK {
break
}
// Rate limit - retry with exponential backoff
if resp.StatusCode == http.StatusTooManyRequests && attempt < maxRetries-1 {
resp.Body.Close()
backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s
time.Sleep(backoff)
continue
}
// Other errors - return immediately
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("Drive API error (status %d): %s", resp.StatusCode, string(body))
}
defer resp.Body.Close()
// Parse response
var result struct {
StorageQuota struct {
Limit string `json:"limit"` // Can be string or number
Usage string `json:"usage"`
} `json:"storageQuota"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// Parse limit and usage (handle both string and number formats)
var limit, usage int64
if result.StorageQuota.Limit != "" {
fmt.Sscanf(result.StorageQuota.Limit, "%d", &limit)
}
if result.StorageQuota.Usage != "" {
fmt.Sscanf(result.StorageQuota.Usage, "%d", &usage)
}
return &DriveStorageInfo{
Limit: limit,
Usage: usage,
}, nil
}

View File

@@ -110,6 +110,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts.DELETE("/:id", h.Admin.Account.Delete) accounts.DELETE("/:id", h.Admin.Account.Delete)
accounts.POST("/:id/test", h.Admin.Account.Test) accounts.POST("/:id/test", h.Admin.Account.Test)
accounts.POST("/:id/refresh", h.Admin.Account.Refresh) accounts.POST("/:id/refresh", h.Admin.Account.Refresh)
accounts.POST("/:id/refresh-tier", h.Admin.Account.RefreshTier)
accounts.GET("/:id/stats", h.Admin.Account.GetStats) accounts.GET("/:id/stats", h.Admin.Account.GetStats)
accounts.POST("/:id/clear-error", h.Admin.Account.ClearError) accounts.POST("/:id/clear-error", h.Admin.Account.ClearError)
accounts.GET("/:id/usage", h.Admin.Account.GetUsage) accounts.GET("/:id/usage", h.Admin.Account.GetUsage)
@@ -119,6 +120,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels) accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
accounts.POST("/batch", h.Admin.Account.BatchCreate) accounts.POST("/batch", h.Admin.Account.BatchCreate)
accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials) accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials)
accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier)
accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate) accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate)
// Claude OAuth routes // Claude OAuth routes

View File

@@ -17,6 +17,15 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
) )
const (
TierAIPremium = "AI_PREMIUM"
TierGoogleOneStandard = "GOOGLE_ONE_STANDARD"
TierGoogleOneBasic = "GOOGLE_ONE_BASIC"
TierFree = "FREE"
TierGoogleOneUnknown = "GOOGLE_ONE_UNKNOWN"
TierGoogleOneUnlimited = "GOOGLE_ONE_UNLIMITED"
)
type GeminiOAuthService struct { type GeminiOAuthService struct {
sessionStore *geminicli.SessionStore sessionStore *geminicli.SessionStore
proxyRepo ProxyRepository proxyRepo ProxyRepository
@@ -89,13 +98,14 @@ func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64
// OAuth client selection: // OAuth client selection:
// - code_assist: always use built-in Gemini CLI OAuth client (public), regardless of configured client_id/secret. // - code_assist: always use built-in Gemini CLI OAuth client (public), regardless of configured client_id/secret.
// - google_one: same as code_assist, uses built-in client for personal Google accounts.
// - ai_studio: requires a user-provided OAuth client. // - ai_studio: requires a user-provided OAuth client.
oauthCfg := geminicli.OAuthConfig{ oauthCfg := geminicli.OAuthConfig{
ClientID: s.cfg.Gemini.OAuth.ClientID, ClientID: s.cfg.Gemini.OAuth.ClientID,
ClientSecret: s.cfg.Gemini.OAuth.ClientSecret, ClientSecret: s.cfg.Gemini.OAuth.ClientSecret,
Scopes: s.cfg.Gemini.OAuth.Scopes, Scopes: s.cfg.Gemini.OAuth.Scopes,
} }
if oauthType == "code_assist" { if oauthType == "code_assist" || oauthType == "google_one" {
oauthCfg.ClientID = "" oauthCfg.ClientID = ""
oauthCfg.ClientSecret = "" oauthCfg.ClientSecret = ""
} }
@@ -156,15 +166,16 @@ type GeminiExchangeCodeInput struct {
} }
type GeminiTokenInfo struct { type GeminiTokenInfo struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"` ExpiresIn int64 `json:"expires_in"`
ExpiresAt int64 `json:"expires_at"` ExpiresAt int64 `json:"expires_at"`
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
Scope string `json:"scope,omitempty"` Scope string `json:"scope,omitempty"`
ProjectID string `json:"project_id,omitempty"` ProjectID string `json:"project_id,omitempty"`
OAuthType string `json:"oauth_type,omitempty"` // "code_assist" 或 "ai_studio" OAuthType string `json:"oauth_type,omitempty"` // "code_assist" 或 "ai_studio"
TierID string `json:"tier_id,omitempty"` // Gemini Code Assist tier: LEGACY/PRO/ULTRA TierID string `json:"tier_id,omitempty"` // Gemini Code Assist tier: LEGACY/PRO/ULTRA
Extra map[string]any `json:"extra,omitempty"` // Drive metadata
} }
// validateTierID validates tier_id format and length // validateTierID validates tier_id format and length
@@ -205,6 +216,60 @@ func extractTierIDFromAllowedTiers(allowedTiers []geminicli.AllowedTier) string
return tierID return tierID
} }
// inferGoogleOneTier infers Google One tier from Drive storage limit
func inferGoogleOneTier(storageBytes int64) string {
if storageBytes <= 0 {
return TierGoogleOneUnknown
}
// Unlimited storage (G Suite legacy)
if storageBytes > 100*1024*1024*1024*1024 { // > 100TB
return TierGoogleOneUnlimited
}
// AI Premium (2TB+)
if storageBytes >= 2*1024*1024*1024*1024 { // >= 2TB
return TierAIPremium
}
// Google One Standard (200GB)
if storageBytes >= 200*1024*1024*1024 { // >= 200GB
return TierGoogleOneStandard
}
// Google One Basic (100GB)
if storageBytes >= 100*1024*1024*1024 { // >= 100GB
return TierGoogleOneBasic
}
// Free (15GB)
if storageBytes >= 15*1024*1024*1024 { // >= 15GB
return TierFree
}
return TierGoogleOneUnknown
}
// fetchGoogleOneTier fetches Google One tier from Drive API
func (s *GeminiOAuthService) FetchGoogleOneTier(ctx context.Context, accessToken, proxyURL string) (string, *geminicli.DriveStorageInfo, error) {
driveClient := geminicli.NewDriveClient()
storageInfo, err := driveClient.GetStorageQuota(ctx, accessToken, proxyURL)
if err != nil {
// Check if it's a 403 (scope not granted)
if strings.Contains(err.Error(), "status 403") {
fmt.Printf("[GeminiOAuth] Drive API scope not available: %v\n", err)
return TierGoogleOneUnknown, nil, err
}
// Other errors
fmt.Printf("[GeminiOAuth] Failed to fetch Drive storage: %v\n", err)
return TierGoogleOneUnknown, nil, err
}
tierID := inferGoogleOneTier(storageInfo.Limit)
return tierID, storageInfo, nil
}
func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExchangeCodeInput) (*GeminiTokenInfo, error) { func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExchangeCodeInput) (*GeminiTokenInfo, error) {
session, ok := s.sessionStore.Get(input.SessionID) session, ok := s.sessionStore.Get(input.SessionID)
if !ok { if !ok {
@@ -272,7 +337,8 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
projectID := sessionProjectID projectID := sessionProjectID
var tierID string var tierID string
// 对于 code_assist 模式project_id 是必需的 // 对于 code_assist 模式project_id 是必需的,需要调用 Code Assist API
// 对于 google_one 模式,使用个人 Google 账号,不需要 project_id配额由 Google 网关自动识别
// 对于 ai_studio 模式project_id 是可选的(不影响使用 AI Studio API // 对于 ai_studio 模式project_id 是可选的(不影响使用 AI Studio API
if oauthType == "code_assist" { if oauthType == "code_assist" {
if projectID == "" { if projectID == "" {
@@ -298,7 +364,37 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
if tierID == "" { if tierID == "" {
tierID = "LEGACY" tierID = "LEGACY"
} }
} else if oauthType == "google_one" {
// Attempt to fetch Drive storage tier
tierID, storageInfo, err := s.FetchGoogleOneTier(ctx, tokenResp.AccessToken, proxyURL)
if err != nil {
// Log warning but don't block - use fallback
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch Drive tier: %v\n", err)
tierID = TierGoogleOneUnknown
}
// Store Drive info in extra field for caching
if storageInfo != nil {
tokenInfo := &GeminiTokenInfo{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
TokenType: tokenResp.TokenType,
ExpiresIn: tokenResp.ExpiresIn,
ExpiresAt: expiresAt,
Scope: tokenResp.Scope,
ProjectID: projectID,
TierID: tierID,
OAuthType: oauthType,
Extra: map[string]any{
"drive_storage_limit": storageInfo.Limit,
"drive_storage_usage": storageInfo.Usage,
"drive_tier_updated_at": time.Now().Format(time.RFC3339),
},
}
return tokenInfo, nil
}
} }
// ai_studio 模式不设置 tierID保持为空
return &GeminiTokenInfo{ return &GeminiTokenInfo{
AccessToken: tokenResp.AccessToken, AccessToken: tokenResp.AccessToken,
@@ -455,6 +551,41 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
if strings.TrimSpace(tokenInfo.ProjectID) == "" { if strings.TrimSpace(tokenInfo.ProjectID) == "" {
return nil, fmt.Errorf("failed to auto-detect project_id: empty result") return nil, fmt.Errorf("failed to auto-detect project_id: empty result")
} }
} else if oauthType == "google_one" {
// Check if tier cache is stale (> 24 hours)
needsRefresh := true
if account.Extra != nil {
if updatedAtStr, ok := account.Extra["drive_tier_updated_at"].(string); ok {
if updatedAt, err := time.Parse(time.RFC3339, updatedAtStr); err == nil {
if time.Since(updatedAt) <= 24*time.Hour {
needsRefresh = false
// Use cached tier
if existingTierID != "" {
tokenInfo.TierID = existingTierID
}
}
}
}
}
if needsRefresh {
tierID, storageInfo, err := s.FetchGoogleOneTier(ctx, tokenInfo.AccessToken, proxyURL)
if err == nil && storageInfo != nil {
tokenInfo.TierID = tierID
tokenInfo.Extra = map[string]any{
"drive_storage_limit": storageInfo.Limit,
"drive_storage_usage": storageInfo.Usage,
"drive_tier_updated_at": time.Now().Format(time.RFC3339),
}
} else {
// Fallback to cached or unknown
if existingTierID != "" {
tokenInfo.TierID = existingTierID
} else {
tokenInfo.TierID = TierGoogleOneUnknown
}
}
}
} }
return tokenInfo, nil return tokenInfo, nil
@@ -487,6 +618,12 @@ func (s *GeminiOAuthService) BuildAccountCredentials(tokenInfo *GeminiTokenInfo)
if tokenInfo.OAuthType != "" { if tokenInfo.OAuthType != "" {
creds["oauth_type"] = tokenInfo.OAuthType creds["oauth_type"] = tokenInfo.OAuthType
} }
// Store extra metadata (Drive info) if present
if len(tokenInfo.Extra) > 0 {
for k, v := range tokenInfo.Extra {
creds[k] = v
}
}
return creds return creds
} }

View File

@@ -48,6 +48,12 @@ const isCodeAssist = computed(() => {
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id) return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
}) })
// 是否为 Google One OAuth
const isGoogleOne = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
return creds?.oauth_type === 'google_one'
})
// 是否应该显示配额信息 // 是否应该显示配额信息
const shouldShowQuota = computed(() => { const shouldShowQuota = computed(() => {
return props.account.platform === 'gemini' return props.account.platform === 'gemini'
@@ -55,33 +61,73 @@ const shouldShowQuota = computed(() => {
// Tier 标签文本 // Tier 标签文本
const tierLabel = computed(() => { const tierLabel = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
if (isCodeAssist.value) { if (isCodeAssist.value) {
const creds = props.account.credentials as GeminiCredentials | undefined // GCP Code Assist: 显示 GCP tier
const tierMap: Record<string, string> = { const tierMap: Record<string, string> = {
LEGACY: 'Free', LEGACY: 'Free',
PRO: 'Pro', PRO: 'Pro',
ULTRA: 'Ultra' ULTRA: 'Ultra',
'standard-tier': 'Standard',
'pro-tier': 'Pro',
'ultra-tier': 'Ultra'
} }
return tierMap[creds?.tier_id || ''] || 'Unknown' return tierMap[creds?.tier_id || ''] || (creds?.tier_id ? 'GCP' : 'Unknown')
} }
if (isGoogleOne.value) {
// Google One: tier 映射
const tierMap: Record<string, string> = {
AI_PREMIUM: 'AI Premium',
GOOGLE_ONE_STANDARD: 'Standard',
GOOGLE_ONE_BASIC: 'Basic',
FREE: 'Free',
GOOGLE_ONE_UNKNOWN: 'Personal',
GOOGLE_ONE_UNLIMITED: 'Unlimited'
}
return tierMap[creds?.tier_id || ''] || 'Personal'
}
// AI Studio 或其他
return 'Gemini' return 'Gemini'
}) })
// Tier Badge 样式 // Tier Badge 样式
const tierBadgeClass = computed(() => { const tierBadgeClass = computed(() => {
if (!isCodeAssist.value) {
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
}
const creds = props.account.credentials as GeminiCredentials | undefined const creds = props.account.credentials as GeminiCredentials | undefined
const tierColorMap: Record<string, string> = {
LEGACY: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', if (isCodeAssist.value) {
PRO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', // GCP Code Assist 样式
ULTRA: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' const tierColorMap: Record<string, string> = {
LEGACY: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
PRO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
ULTRA: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
'standard-tier': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
'pro-tier': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
'ultra-tier': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}
return (
tierColorMap[creds?.tier_id || ''] ||
'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
)
} }
return (
tierColorMap[creds?.tier_id || ''] || if (isGoogleOne.value) {
'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400' // Google One tier 样式
) const tierColorMap: Record<string, string> = {
AI_PREMIUM: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
GOOGLE_ONE_BASIC: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
FREE: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}
return tierColorMap[creds?.tier_id || ''] || 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
}
// AI Studio 默认样式:蓝色
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
}) })
// 是否限流 // 是否限流

View File

@@ -568,6 +568,24 @@ const isGeminiCodeAssist = computed(() => {
// Gemini 账户类型显示标签 // Gemini 账户类型显示标签
const geminiTierLabel = computed(() => { const geminiTierLabel = computed(() => {
if (!geminiTier.value) return null if (!geminiTier.value) return null
const creds = props.account.credentials as GeminiCredentials | undefined
const isGoogleOne = creds?.oauth_type === 'google_one'
if (isGoogleOne) {
// Google One tier 标签
const tierMap: Record<string, string> = {
AI_PREMIUM: t('admin.accounts.tier.aiPremium'),
GOOGLE_ONE_STANDARD: t('admin.accounts.tier.standard'),
GOOGLE_ONE_BASIC: t('admin.accounts.tier.basic'),
FREE: t('admin.accounts.tier.free'),
GOOGLE_ONE_UNKNOWN: t('admin.accounts.tier.personal'),
GOOGLE_ONE_UNLIMITED: t('admin.accounts.tier.unlimited')
}
return tierMap[geminiTier.value] || t('admin.accounts.tier.personal')
}
// Code Assist tier 标签
const tierMap: Record<string, string> = { const tierMap: Record<string, string> = {
LEGACY: t('admin.accounts.tier.free'), LEGACY: t('admin.accounts.tier.free'),
PRO: t('admin.accounts.tier.pro'), PRO: t('admin.accounts.tier.pro'),
@@ -578,6 +596,25 @@ const geminiTierLabel = computed(() => {
// Gemini 账户类型徽章样式 // Gemini 账户类型徽章样式
const geminiTierClass = computed(() => { const geminiTierClass = computed(() => {
if (!geminiTier.value) return ''
const creds = props.account.credentials as GeminiCredentials | undefined
const isGoogleOne = creds?.oauth_type === 'google_one'
if (isGoogleOne) {
// Google One tier 颜色
const colorMap: Record<string, string> = {
AI_PREMIUM: 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300',
GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300',
GOOGLE_ONE_BASIC: 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-300',
FREE: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300'
}
return colorMap[geminiTier.value] || 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
}
// Code Assist tier 颜色
switch (geminiTier.value) { switch (geminiTier.value) {
case 'LEGACY': case 'LEGACY':
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'

View File

@@ -455,6 +455,52 @@
<div v-if="accountCategory === 'oauth-based'" class="mt-4"> <div v-if="accountCategory === 'oauth-based'" class="mt-4">
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label> <label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label>
<div class="mt-2 grid grid-cols-2 gap-3"> <div class="mt-2 grid grid-cols-2 gap-3">
<!-- Google One OAuth -->
<button
type="button"
@click="handleSelectGeminiOAuthType('google_one')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'google_one'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<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 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
Google One
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
个人账号享受 Google One 订阅配额
</span>
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded bg-purple-100 px-2 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
>
推荐个人用户
</span>
<span
class="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
>
无需 GCP
</span>
</div>
</div>
</button>
<!-- GCP Code Assist OAuth -->
<button <button
type="button" type="button"
@click="handleSelectGeminiOAuthType('code_assist')" @click="handleSelectGeminiOAuthType('code_assist')"
@@ -479,13 +525,13 @@
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white"> <span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.builtInTitle') }} GCP Code Assist
</span> </span>
<span class="text-xs text-gray-500 dark:text-gray-400"> <span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.builtInDesc') }} 企业级需要 GCP 项目
</span> </span>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.builtInRequirement') }} 需要激活 GCP 项目并绑定信用卡
<a <a
:href="geminiHelpLinks.gcpProject" :href="geminiHelpLinks.gcpProject"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400" class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
@@ -499,94 +545,110 @@
<span <span
class="rounded bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" class="rounded bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
> >
{{ t('admin.accounts.gemini.oauthType.badges.recommended') }} 企业用户
</span> </span>
<span <span
class="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300" class="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
> >
{{ t('admin.accounts.gemini.oauthType.badges.highConcurrency') }} 高并发
</span>
<span
class="rounded bg-gray-100 px-2 py-0.5 text-[10px] font-semibold text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.noAdmin') }}
</span> </span>
</div> </div>
</div> </div>
</button> </button>
</div>
<div class="group relative"> <!-- Advanced Options Toggle -->
<button <div class="mt-3">
type="button" <button
:disabled="!geminiAIStudioOAuthEnabled" type="button"
@click="handleSelectGeminiOAuthType('ai_studio')" @click="showAdvancedOAuth = !showAdvancedOAuth"
class="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
<svg
:class="['h-4 w-4 transition-transform', showAdvancedOAuth ? 'rotate-90' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span>{{ showAdvancedOAuth ? '隐藏' : '显示' }}高级选项自建 OAuth Client</span>
</button>
</div>
<!-- Custom OAuth Client (Advanced) -->
<div v-if="showAdvancedOAuth" class="mt-3 group relative">
<button
type="button"
:disabled="!geminiAIStudioOAuthEnabled"
@click="handleSelectGeminiOAuthType('ai_studio')"
:class="[
'flex w-full items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio'
? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20'
: 'border-gray-200 hover:border-amber-300 dark:border-dark-600 dark:hover:border-amber-700'
]"
>
<div
:class="[ :class="[
'flex w-full items-center gap-3 rounded-lg border-2 p-3 text-left transition-all', 'flex h-8 w-8 items-center justify-center rounded-lg',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio' geminiOAuthType === 'ai_studio'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' ? 'bg-amber-500 text-white'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]" ]"
> >
<div <svg
:class="[ class="h-4 w-4"
'flex h-8 w-8 items-center justify-center rounded-lg', fill="none"
geminiOAuthType === 'ai_studio' viewBox="0 0 24 24"
? 'bg-purple-500 text-white' stroke="currentColor"
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' stroke-width="1.5"
]"
> >
<svg <path
class="h-4 w-4" stroke-linecap="round"
fill="none" stroke-linejoin="round"
viewBox="0 0 24 24" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
stroke="currentColor" />
stroke-width="1.5" </svg>
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.customTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customDesc') }}
</span>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customRequirement') }}
</div>
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded bg-purple-100 px-2 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.orgManaged') }}
</span>
<span
class="rounded bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.adminRequired') }}
</span>
</div>
</div>
<span
v-if="!geminiAIStudioOAuthEnabled"
class="ml-auto shrink-0 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }}
</span>
</button>
<div
v-if="!geminiAIStudioOAuthEnabled"
class="pointer-events-none absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
</div> </div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.customTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customDesc') }}
</span>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customRequirement') }}
</div>
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.orgManaged') }}
</span>
<span
class="rounded bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.adminRequired') }}
</span>
</div>
</div>
<span
v-if="!geminiAIStudioOAuthEnabled"
class="ml-auto shrink-0 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }}
</span>
</button>
<div
v-if="!geminiAIStudioOAuthEnabled"
class="pointer-events-none absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
</div> </div>
</div> </div>
</div> </div>
@@ -1610,8 +1672,9 @@ const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist') const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
const geminiAIStudioOAuthEnabled = ref(false) const geminiAIStudioOAuthEnabled = ref(false)
const showAdvancedOAuth = ref(false)
// Common models for whitelist - Anthropic // Common models for whitelist - Anthropic
const anthropicModels = [ const anthropicModels = [
@@ -1902,7 +1965,7 @@ watch(
{ immediate: true } { immediate: true }
) )
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'ai_studio') => { const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'google_one' | 'ai_studio') => {
if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) { if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured')) appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
return return

View File

@@ -93,7 +93,13 @@ export function useGeminiOAuth() {
const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any) const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any)
return tokenInfo as GeminiTokenInfo return tokenInfo as GeminiTokenInfo
} catch (err: any) { } catch (err: any) {
error.value = err.response?.data?.detail || t('admin.accounts.oauth.gemini.failedToExchangeCode') // Check for specific missing project_id error
const errorMessage = err.message || err.response?.data?.message || ''
if (errorMessage.includes('missing project_id')) {
error.value = t('admin.accounts.oauth.gemini.missingProjectId')
} else {
error.value = errorMessage || t('admin.accounts.oauth.gemini.failedToExchangeCode')
}
appStore.showError(error.value) appStore.showError(error.value)
return null return null
} finally { } finally {

View File

@@ -1076,6 +1076,7 @@ export default {
failedToGenerateUrl: 'Failed to generate Gemini auth URL', failedToGenerateUrl: 'Failed to generate Gemini auth URL',
missingExchangeParams: 'Missing auth code, session ID, or state', missingExchangeParams: 'Missing auth code, session ID, or state',
failedToExchangeCode: 'Failed to exchange Gemini auth code', failedToExchangeCode: 'Failed to exchange Gemini auth code',
missingProjectId: 'GCP Project ID retrieval failed: Your Google account is not linked to an active GCP project. Please activate GCP and bind a credit card in Google Cloud Console, or manually enter the Project ID during authorization.',
modelPassthrough: 'Gemini Model Passthrough', modelPassthrough: 'Gemini Model Passthrough',
modelPassthroughDesc: modelPassthroughDesc:
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
@@ -1290,7 +1291,12 @@ export default {
tier: { tier: {
free: 'Free', free: 'Free',
pro: 'Pro', pro: 'Pro',
ultra: 'Ultra' ultra: 'Ultra',
aiPremium: 'AI Premium',
standard: 'Standard',
basic: 'Basic',
personal: 'Personal',
unlimited: 'Unlimited'
}, },
ineligibleWarning: ineligibleWarning:
'This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.' 'This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.'

View File

@@ -996,7 +996,12 @@ export default {
tier: { tier: {
free: 'Free', free: 'Free',
pro: 'Pro', pro: 'Pro',
ultra: 'Ultra' ultra: 'Ultra',
aiPremium: 'AI Premium',
standard: '标准版',
basic: '基础版',
personal: '个人版',
unlimited: '无限制'
}, },
ineligibleWarning: ineligibleWarning:
'该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。', '该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。',
@@ -1215,6 +1220,7 @@ export default {
failedToGenerateUrl: '生成 Gemini 授权链接失败', failedToGenerateUrl: '生成 Gemini 授权链接失败',
missingExchangeParams: '缺少 code / session_id / state', missingExchangeParams: '缺少 code / session_id / state',
failedToExchangeCode: 'Gemini 授权码兑换失败', failedToExchangeCode: 'Gemini 授权码兑换失败',
missingProjectId: 'GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。',
modelPassthrough: 'Gemini 直接转发模型', modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API不进行模型限制或映射。', modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API不进行模型限制或映射。',
stateWarningTitle: '提示', stateWarningTitle: '提示',