feat(gemini): 完善 Gemini OAuth 配额系统和用量显示
主要改动: - 后端:重构 Gemini 配额服务,支持多层级配额策略(GCP Standard/Free, Google One, AI Studio, Code Assist) - 后端:优化 OAuth 服务,增强 tier_id 识别和存储逻辑 - 后端:改进用量统计服务,支持不同平台的配额查询 - 后端:优化限流服务,增加临时解除调度状态管理 - 前端:统一四种授权方式的用量显示格式和徽标样式 - 前端:增强账户配额信息展示,支持多种配额类型 - 前端:改进创建和重新授权模态框的用户体验 - 国际化:完善中英文配额相关文案 - 移除 CHANGELOG.md 文件 测试:所有单元测试通过
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,17 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented in this file.
|
||||
|
||||
The format is based on Keep a Changelog, and this project aims to follow Semantic Versioning.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Admin ops error logs: `GET /api/v1/admin/ops/error-logs` now enforces `limit <= 500` (previously `<= 5000`). Requests with `limit > 500` return `400 Bad Request` (`Invalid limit (must be 1-500)`).
|
||||
|
||||
### Migration
|
||||
|
||||
- Prefer the paginated endpoint `GET /api/v1/admin/ops/errors` using `page` / `page_size`.
|
||||
- If you must keep using `.../error-logs`, reduce `limit` to `<= 500` and fetch multiple pages by splitting queries (e.g., by time window) instead of requesting a single large result set.
|
||||
|
||||
@@ -30,6 +30,8 @@ type GeminiGenerateAuthURLRequest struct {
|
||||
// OAuth 类型: "code_assist" (需要 project_id) 或 "ai_studio" (不需要 project_id)
|
||||
// 默认为 "code_assist" 以保持向后兼容
|
||||
OAuthType string `json:"oauth_type"`
|
||||
// TierID is a user-selected tier to be used when auto detection is unavailable or fails.
|
||||
TierID string `json:"tier_id"`
|
||||
}
|
||||
|
||||
// GenerateAuthURL generates Google OAuth authorization URL for Gemini.
|
||||
@@ -54,7 +56,7 @@ func (h *GeminiOAuthHandler) GenerateAuthURL(c *gin.Context) {
|
||||
// Always pass the "hosted" callback URI; the OAuth service may override it depending on
|
||||
// oauth_type and whether the built-in Gemini CLI OAuth client is used.
|
||||
redirectURI := deriveGeminiRedirectURI(c)
|
||||
result, err := h.geminiOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID, redirectURI, req.ProjectID, oauthType)
|
||||
result, err := h.geminiOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID, redirectURI, req.ProjectID, oauthType, req.TierID)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
// Treat missing/invalid OAuth client configuration as a user/config error.
|
||||
@@ -76,6 +78,9 @@ type GeminiExchangeCodeRequest struct {
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
// OAuth 类型: "code_assist" 或 "ai_studio",需要与 GenerateAuthURL 时的类型一致
|
||||
OAuthType string `json:"oauth_type"`
|
||||
// TierID is a user-selected tier to be used when auto detection is unavailable or fails.
|
||||
// This field is optional; when omitted, the server uses the tier stored in the OAuth session.
|
||||
TierID string `json:"tier_id"`
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges authorization code for tokens.
|
||||
@@ -103,6 +108,7 @@ func (h *GeminiOAuthHandler) ExchangeCode(c *gin.Context) {
|
||||
Code: req.Code,
|
||||
ProxyID: req.ProxyID,
|
||||
OAuthType: oauthType,
|
||||
TierID: req.TierID,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Failed to exchange code: "+err.Error())
|
||||
|
||||
@@ -19,13 +19,17 @@ type OAuthConfig struct {
|
||||
}
|
||||
|
||||
type OAuthSession struct {
|
||||
State string `json:"state"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
OAuthType string `json:"oauth_type"` // "code_assist" 或 "ai_studio"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
State string `json:"state"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
// TierID is a user-selected fallback tier.
|
||||
// For oauth types that support auto detection (google_one/code_assist), the server will prefer
|
||||
// the detected tier and fall back to TierID when detection fails.
|
||||
TierID string `json:"tier_id,omitempty"`
|
||||
OAuthType string `json:"oauth_type"` // "code_assist" 或 "ai_studio"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type SessionStore struct {
|
||||
|
||||
@@ -30,14 +30,14 @@ func (c *geminiOAuthClient) ExchangeCode(ctx context.Context, oauthType, code, c
|
||||
|
||||
// Use different OAuth clients based on oauthType:
|
||||
// - code_assist: always use built-in Gemini CLI OAuth client (public)
|
||||
// - google_one: same as code_assist, uses built-in client for personal Google accounts
|
||||
// - google_one: uses configured OAuth client when provided; otherwise falls back to built-in client
|
||||
// - ai_studio: requires a user-provided OAuth client
|
||||
oauthCfgInput := geminicli.OAuthConfig{
|
||||
ClientID: c.cfg.Gemini.OAuth.ClientID,
|
||||
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
|
||||
Scopes: c.cfg.Gemini.OAuth.Scopes,
|
||||
}
|
||||
if oauthType == "code_assist" || oauthType == "google_one" {
|
||||
if oauthType == "code_assist" {
|
||||
oauthCfgInput.ClientID = ""
|
||||
oauthCfgInput.ClientSecret = ""
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func (c *geminiOAuthClient) RefreshToken(ctx context.Context, oauthType, refresh
|
||||
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
|
||||
Scopes: c.cfg.Gemini.OAuth.Scopes,
|
||||
}
|
||||
if oauthType == "code_assist" || oauthType == "google_one" {
|
||||
if oauthType == "code_assist" {
|
||||
oauthCfgInput.ClientID = ""
|
||||
oauthCfgInput.ClientSecret = ""
|
||||
}
|
||||
|
||||
@@ -105,10 +105,7 @@ func (a *Account) GeminiOAuthType() string {
|
||||
|
||||
func (a *Account) GeminiTierID() string {
|
||||
tierID := strings.TrimSpace(a.GetCredential("tier_id"))
|
||||
if tierID == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(tierID)
|
||||
return tierID
|
||||
}
|
||||
|
||||
func (a *Account) IsGeminiCodeAssist() bool {
|
||||
|
||||
@@ -107,6 +107,8 @@ type UsageProgress struct {
|
||||
ResetsAt *time.Time `json:"resets_at"` // 重置时间
|
||||
RemainingSeconds int `json:"remaining_seconds"` // 距重置剩余秒数
|
||||
WindowStats *WindowStats `json:"window_stats,omitempty"` // 窗口期统计(从窗口开始到当前的使用量)
|
||||
UsedRequests int64 `json:"used_requests,omitempty"`
|
||||
LimitRequests int64 `json:"limit_requests,omitempty"`
|
||||
}
|
||||
|
||||
// AntigravityModelQuota Antigravity 单个模型的配额信息
|
||||
@@ -117,12 +119,16 @@ type AntigravityModelQuota struct {
|
||||
|
||||
// UsageInfo 账号使用量信息
|
||||
type UsageInfo struct {
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
|
||||
FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口
|
||||
SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口
|
||||
SevenDaySonnet *UsageProgress `json:"seven_day_sonnet,omitempty"` // 7天Sonnet窗口
|
||||
GeminiProDaily *UsageProgress `json:"gemini_pro_daily,omitempty"` // Gemini Pro 日配额
|
||||
GeminiFlashDaily *UsageProgress `json:"gemini_flash_daily,omitempty"` // Gemini Flash 日配额
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
|
||||
FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口
|
||||
SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口
|
||||
SevenDaySonnet *UsageProgress `json:"seven_day_sonnet,omitempty"` // 7天Sonnet窗口
|
||||
GeminiSharedDaily *UsageProgress `json:"gemini_shared_daily,omitempty"` // Gemini shared pool RPD (Google One / Code Assist)
|
||||
GeminiProDaily *UsageProgress `json:"gemini_pro_daily,omitempty"` // Gemini Pro 日配额
|
||||
GeminiFlashDaily *UsageProgress `json:"gemini_flash_daily,omitempty"` // Gemini Flash 日配额
|
||||
GeminiSharedMinute *UsageProgress `json:"gemini_shared_minute,omitempty"` // Gemini shared pool RPM (Google One / Code Assist)
|
||||
GeminiProMinute *UsageProgress `json:"gemini_pro_minute,omitempty"` // Gemini Pro RPM
|
||||
GeminiFlashMinute *UsageProgress `json:"gemini_flash_minute,omitempty"` // Gemini Flash RPM
|
||||
|
||||
// Antigravity 多模型配额
|
||||
AntigravityQuota map[string]*AntigravityModelQuota `json:"antigravity_quota,omitempty"`
|
||||
@@ -258,17 +264,44 @@ func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Accou
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
start := geminiDailyWindowStart(now)
|
||||
stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
|
||||
dayStart := geminiDailyWindowStart(now)
|
||||
stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, dayStart, now, 0, 0, account.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get gemini usage stats failed: %w", err)
|
||||
}
|
||||
|
||||
totals := geminiAggregateUsage(stats)
|
||||
resetAt := geminiDailyResetTime(now)
|
||||
dayTotals := geminiAggregateUsage(stats)
|
||||
dailyResetAt := geminiDailyResetTime(now)
|
||||
|
||||
usage.GeminiProDaily = buildGeminiUsageProgress(totals.ProRequests, quota.ProRPD, resetAt, totals.ProTokens, totals.ProCost, now)
|
||||
usage.GeminiFlashDaily = buildGeminiUsageProgress(totals.FlashRequests, quota.FlashRPD, resetAt, totals.FlashTokens, totals.FlashCost, now)
|
||||
// Daily window (RPD)
|
||||
if quota.SharedRPD > 0 {
|
||||
totalReq := dayTotals.ProRequests + dayTotals.FlashRequests
|
||||
totalTokens := dayTotals.ProTokens + dayTotals.FlashTokens
|
||||
totalCost := dayTotals.ProCost + dayTotals.FlashCost
|
||||
usage.GeminiSharedDaily = buildGeminiUsageProgress(totalReq, quota.SharedRPD, dailyResetAt, totalTokens, totalCost, now)
|
||||
} else {
|
||||
usage.GeminiProDaily = buildGeminiUsageProgress(dayTotals.ProRequests, quota.ProRPD, dailyResetAt, dayTotals.ProTokens, dayTotals.ProCost, now)
|
||||
usage.GeminiFlashDaily = buildGeminiUsageProgress(dayTotals.FlashRequests, quota.FlashRPD, dailyResetAt, dayTotals.FlashTokens, dayTotals.FlashCost, now)
|
||||
}
|
||||
|
||||
// Minute window (RPM) - fixed-window approximation: current minute [truncate(now), truncate(now)+1m)
|
||||
minuteStart := now.Truncate(time.Minute)
|
||||
minuteResetAt := minuteStart.Add(time.Minute)
|
||||
minuteStats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, minuteStart, now, 0, 0, account.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get gemini minute usage stats failed: %w", err)
|
||||
}
|
||||
minuteTotals := geminiAggregateUsage(minuteStats)
|
||||
|
||||
if quota.SharedRPM > 0 {
|
||||
totalReq := minuteTotals.ProRequests + minuteTotals.FlashRequests
|
||||
totalTokens := minuteTotals.ProTokens + minuteTotals.FlashTokens
|
||||
totalCost := minuteTotals.ProCost + minuteTotals.FlashCost
|
||||
usage.GeminiSharedMinute = buildGeminiUsageProgress(totalReq, quota.SharedRPM, minuteResetAt, totalTokens, totalCost, now)
|
||||
} else {
|
||||
usage.GeminiProMinute = buildGeminiUsageProgress(minuteTotals.ProRequests, quota.ProRPM, minuteResetAt, minuteTotals.ProTokens, minuteTotals.ProCost, now)
|
||||
usage.GeminiFlashMinute = buildGeminiUsageProgress(minuteTotals.FlashRequests, quota.FlashRPM, minuteResetAt, minuteTotals.FlashTokens, minuteTotals.FlashCost, now)
|
||||
}
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
@@ -508,6 +541,7 @@ func (s *AccountUsageService) estimateSetupTokenUsage(account *Account) *UsageIn
|
||||
}
|
||||
|
||||
func buildGeminiUsageProgress(used, limit int64, resetAt time.Time, tokens int64, cost float64, now time.Time) *UsageProgress {
|
||||
// limit <= 0 means "no local quota window" (unknown or unlimited).
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -521,6 +555,8 @@ func buildGeminiUsageProgress(used, limit int64, resetAt time.Time, tokens int64
|
||||
Utilization: utilization,
|
||||
ResetsAt: &resetCopy,
|
||||
RemainingSeconds: remainingSeconds,
|
||||
UsedRequests: used,
|
||||
LimitRequests: limit,
|
||||
WindowStats: &WindowStats{
|
||||
Requests: used,
|
||||
Tokens: tokens,
|
||||
|
||||
@@ -1064,6 +1064,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
}
|
||||
|
||||
// 不需要重试(成功或不可重试的错误),跳出循环
|
||||
// DEBUG: 输出响应 headers(用于检测 rate limit 信息)
|
||||
if account.Platform == PlatformGemini && resp.StatusCode < 400 {
|
||||
log.Printf("[DEBUG] Gemini API Response Headers for account %d:", account.ID)
|
||||
for k, v := range resp.Header {
|
||||
log.Printf("[DEBUG] %s: %v", k, v)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
@@ -1628,6 +1628,15 @@ type UpstreamHTTPResult struct {
|
||||
}
|
||||
|
||||
func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Context, resp *http.Response, isOAuth bool) (*ClaudeUsage, error) {
|
||||
// Log response headers for debugging
|
||||
log.Printf("[GeminiAPI] ========== Response Headers ==========")
|
||||
for key, values := range resp.Header {
|
||||
if strings.HasPrefix(strings.ToLower(key), "x-ratelimit") {
|
||||
log.Printf("[GeminiAPI] %s: %v", key, values)
|
||||
}
|
||||
}
|
||||
log.Printf("[GeminiAPI] ========================================")
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1658,6 +1667,15 @@ func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Co
|
||||
}
|
||||
|
||||
func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time, isOAuth bool) (*geminiNativeStreamResult, error) {
|
||||
// Log response headers for debugging
|
||||
log.Printf("[GeminiAPI] ========== Streaming Response Headers ==========")
|
||||
for key, values := range resp.Header {
|
||||
if strings.HasPrefix(strings.ToLower(key), "x-ratelimit") {
|
||||
log.Printf("[GeminiAPI] %s: %v", key, values)
|
||||
}
|
||||
}
|
||||
log.Printf("[GeminiAPI] ====================================================")
|
||||
|
||||
c.Status(resp.StatusCode)
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -18,12 +19,23 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
TierAIPremium = "AI_PREMIUM"
|
||||
TierGoogleOneStandard = "GOOGLE_ONE_STANDARD"
|
||||
TierGoogleOneBasic = "GOOGLE_ONE_BASIC"
|
||||
TierFree = "FREE"
|
||||
TierGoogleOneUnknown = "GOOGLE_ONE_UNKNOWN"
|
||||
TierGoogleOneUnlimited = "GOOGLE_ONE_UNLIMITED"
|
||||
// Canonical tier IDs used by sub2api (2026-aligned).
|
||||
GeminiTierGoogleOneFree = "google_one_free"
|
||||
GeminiTierGoogleAIPro = "google_ai_pro"
|
||||
GeminiTierGoogleAIUltra = "google_ai_ultra"
|
||||
GeminiTierGCPStandard = "gcp_standard"
|
||||
GeminiTierGCPEnterprise = "gcp_enterprise"
|
||||
GeminiTierAIStudioFree = "aistudio_free"
|
||||
GeminiTierAIStudioPaid = "aistudio_paid"
|
||||
GeminiTierGoogleOneUnknown = "google_one_unknown"
|
||||
|
||||
// Legacy/compat tier IDs that may exist in historical data or upstream responses.
|
||||
legacyTierAIPremium = "AI_PREMIUM"
|
||||
legacyTierGoogleOneStandard = "GOOGLE_ONE_STANDARD"
|
||||
legacyTierGoogleOneBasic = "GOOGLE_ONE_BASIC"
|
||||
legacyTierFree = "FREE"
|
||||
legacyTierGoogleOneUnknown = "GOOGLE_ONE_UNKNOWN"
|
||||
legacyTierGoogleOneUnlimited = "GOOGLE_ONE_UNLIMITED"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -84,7 +96,7 @@ type GeminiAuthURLResult struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64, redirectURI, projectID, oauthType string) (*GeminiAuthURLResult, error) {
|
||||
func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64, redirectURI, projectID, oauthType, tierID string) (*GeminiAuthURLResult, error) {
|
||||
state, err := geminicli.GenerateState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate state: %w", err)
|
||||
@@ -109,14 +121,14 @@ func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64
|
||||
|
||||
// OAuth client selection:
|
||||
// - 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.
|
||||
// - google_one: uses configured OAuth client when provided; otherwise falls back to built-in client.
|
||||
// - ai_studio: requires a user-provided OAuth client.
|
||||
oauthCfg := geminicli.OAuthConfig{
|
||||
ClientID: s.cfg.Gemini.OAuth.ClientID,
|
||||
ClientSecret: s.cfg.Gemini.OAuth.ClientSecret,
|
||||
Scopes: s.cfg.Gemini.OAuth.Scopes,
|
||||
}
|
||||
if oauthType == "code_assist" || oauthType == "google_one" {
|
||||
if oauthType == "code_assist" {
|
||||
oauthCfg.ClientID = ""
|
||||
oauthCfg.ClientSecret = ""
|
||||
}
|
||||
@@ -127,6 +139,7 @@ func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64
|
||||
ProxyURL: proxyURL,
|
||||
RedirectURI: redirectURI,
|
||||
ProjectID: strings.TrimSpace(projectID),
|
||||
TierID: canonicalGeminiTierIDForOAuthType(oauthType, tierID),
|
||||
OAuthType: oauthType,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
@@ -146,9 +159,9 @@ func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64
|
||||
}
|
||||
|
||||
// Redirect URI strategy:
|
||||
// - code_assist: use Gemini CLI redirect URI (codeassist.google.com/authcode)
|
||||
// - ai_studio: use localhost callback for manual copy/paste flow
|
||||
if oauthType == "code_assist" {
|
||||
// - built-in Gemini CLI OAuth client: use upstream redirect URI (codeassist.google.com/authcode)
|
||||
// - custom OAuth client: use localhost callback for manual copy/paste flow
|
||||
if isBuiltinClient {
|
||||
redirectURI = geminicli.GeminiCLIRedirectURI
|
||||
} else {
|
||||
redirectURI = geminicli.AIStudioOAuthRedirectURI
|
||||
@@ -174,6 +187,9 @@ type GeminiExchangeCodeInput struct {
|
||||
Code string
|
||||
ProxyID *int64
|
||||
OAuthType string // "code_assist" 或 "ai_studio"
|
||||
// TierID is a user-selected tier to be used when auto detection is unavailable or fails.
|
||||
// If empty, the service will fall back to the tier stored in the OAuth session (if any).
|
||||
TierID string
|
||||
}
|
||||
|
||||
type GeminiTokenInfo struct {
|
||||
@@ -185,7 +201,7 @@ type GeminiTokenInfo struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
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"` // Canonical tier id (e.g. google_one_free, gcp_standard, aistudio_free)
|
||||
Extra map[string]any `json:"extra,omitempty"` // Drive metadata
|
||||
}
|
||||
|
||||
@@ -204,6 +220,90 @@ func validateTierID(tierID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalGeminiTierID(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lower := strings.ToLower(raw)
|
||||
switch lower {
|
||||
case GeminiTierGoogleOneFree,
|
||||
GeminiTierGoogleAIPro,
|
||||
GeminiTierGoogleAIUltra,
|
||||
GeminiTierGCPStandard,
|
||||
GeminiTierGCPEnterprise,
|
||||
GeminiTierAIStudioFree,
|
||||
GeminiTierAIStudioPaid,
|
||||
GeminiTierGoogleOneUnknown:
|
||||
return lower
|
||||
}
|
||||
|
||||
upper := strings.ToUpper(raw)
|
||||
switch upper {
|
||||
// Google One legacy tiers
|
||||
case legacyTierAIPremium:
|
||||
return GeminiTierGoogleAIPro
|
||||
case legacyTierGoogleOneUnlimited:
|
||||
return GeminiTierGoogleAIUltra
|
||||
case legacyTierFree, legacyTierGoogleOneBasic, legacyTierGoogleOneStandard:
|
||||
return GeminiTierGoogleOneFree
|
||||
case legacyTierGoogleOneUnknown:
|
||||
return GeminiTierGoogleOneUnknown
|
||||
|
||||
// Code Assist legacy tiers
|
||||
case "STANDARD", "PRO", "LEGACY":
|
||||
return GeminiTierGCPStandard
|
||||
case "ENTERPRISE", "ULTRA":
|
||||
return GeminiTierGCPEnterprise
|
||||
}
|
||||
|
||||
// Some Code Assist responses use kebab-case tier identifiers.
|
||||
switch lower {
|
||||
case "standard-tier", "pro-tier":
|
||||
return GeminiTierGCPStandard
|
||||
case "ultra-tier":
|
||||
return GeminiTierGCPEnterprise
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func canonicalGeminiTierIDForOAuthType(oauthType, tierID string) string {
|
||||
oauthType = strings.ToLower(strings.TrimSpace(oauthType))
|
||||
canonical := canonicalGeminiTierID(tierID)
|
||||
if canonical == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch oauthType {
|
||||
case "google_one":
|
||||
switch canonical {
|
||||
case GeminiTierGoogleOneFree, GeminiTierGoogleAIPro, GeminiTierGoogleAIUltra:
|
||||
return canonical
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
case "code_assist":
|
||||
switch canonical {
|
||||
case GeminiTierGCPStandard, GeminiTierGCPEnterprise:
|
||||
return canonical
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
case "ai_studio":
|
||||
switch canonical {
|
||||
case GeminiTierAIStudioFree, GeminiTierAIStudioPaid:
|
||||
return canonical
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
default:
|
||||
// Unknown oauth type: accept canonical tier.
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
|
||||
// extractTierIDFromAllowedTiers extracts tierID from LoadCodeAssist response
|
||||
// Prioritizes IsDefault tier, falls back to first non-empty tier
|
||||
func extractTierIDFromAllowedTiers(allowedTiers []geminicli.AllowedTier) string {
|
||||
@@ -233,55 +333,35 @@ func inferGoogleOneTier(storageBytes int64) string {
|
||||
|
||||
if storageBytes <= 0 {
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - storageBytes <= 0, returning UNKNOWN")
|
||||
return TierGoogleOneUnknown
|
||||
return GeminiTierGoogleOneUnknown
|
||||
}
|
||||
|
||||
if storageBytes > StorageTierUnlimited {
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - > %d bytes (100TB), returning UNLIMITED", StorageTierUnlimited)
|
||||
return TierGoogleOneUnlimited
|
||||
return GeminiTierGoogleAIUltra
|
||||
}
|
||||
if storageBytes >= StorageTierAIPremium {
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - >= %d bytes (2TB), returning AI_PREMIUM", StorageTierAIPremium)
|
||||
return TierAIPremium
|
||||
}
|
||||
if storageBytes >= StorageTierStandard {
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - >= %d bytes (200GB), returning STANDARD", StorageTierStandard)
|
||||
return TierGoogleOneStandard
|
||||
}
|
||||
if storageBytes >= StorageTierBasic {
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - >= %d bytes (100GB), returning BASIC", StorageTierBasic)
|
||||
return TierGoogleOneBasic
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - >= %d bytes (2TB), returning google_ai_pro", StorageTierAIPremium)
|
||||
return GeminiTierGoogleAIPro
|
||||
}
|
||||
if storageBytes >= StorageTierFree {
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - >= %d bytes (15GB), returning FREE", StorageTierFree)
|
||||
return TierFree
|
||||
return GeminiTierGoogleOneFree
|
||||
}
|
||||
|
||||
log.Printf("[GeminiOAuth] inferGoogleOneTier - < %d bytes (15GB), returning UNKNOWN", StorageTierFree)
|
||||
return TierGoogleOneUnknown
|
||||
return GeminiTierGoogleOneUnknown
|
||||
}
|
||||
|
||||
// fetchGoogleOneTier fetches Google One tier from Drive API or LoadCodeAssist API
|
||||
// fetchGoogleOneTier fetches Google One tier from Drive API
|
||||
// Note: LoadCodeAssist API is NOT called for Google One accounts because:
|
||||
// 1. It's designed for GCP IAM (enterprise), not personal Google accounts
|
||||
// 2. Personal accounts will get 403/404 from cloudaicompanion.googleapis.com
|
||||
// 3. Google consumer (Google One) and enterprise (GCP) systems are physically isolated
|
||||
func (s *GeminiOAuthService) FetchGoogleOneTier(ctx context.Context, accessToken, proxyURL string) (string, *geminicli.DriveStorageInfo, error) {
|
||||
log.Printf("[GeminiOAuth] Starting FetchGoogleOneTier")
|
||||
log.Printf("[GeminiOAuth] Starting FetchGoogleOneTier (Google One personal account)")
|
||||
|
||||
// First try LoadCodeAssist API (works for accounts with GCP projects)
|
||||
if s.codeAssist != nil {
|
||||
log.Printf("[GeminiOAuth] Trying LoadCodeAssist API...")
|
||||
loadResp, err := s.codeAssist.LoadCodeAssist(ctx, accessToken, proxyURL, nil)
|
||||
if err != nil {
|
||||
log.Printf("[GeminiOAuth] LoadCodeAssist failed: %v", err)
|
||||
} else if loadResp != nil {
|
||||
if tier := loadResp.GetTier(); tier != "" {
|
||||
log.Printf("[GeminiOAuth] Got tier from LoadCodeAssist: %s (skipping Drive API)", tier)
|
||||
return tier, nil, nil
|
||||
} else {
|
||||
log.Printf("[GeminiOAuth] LoadCodeAssist returned no tier, falling back to Drive API")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Drive API (requires drive.readonly scope)
|
||||
// Use Drive API to infer tier from storage quota (requires drive.readonly scope)
|
||||
log.Printf("[GeminiOAuth] Calling Drive API for storage quota...")
|
||||
driveClient := geminicli.NewDriveClient()
|
||||
|
||||
@@ -290,11 +370,11 @@ func (s *GeminiOAuthService) FetchGoogleOneTier(ctx context.Context, accessToken
|
||||
// Check if it's a 403 (scope not granted)
|
||||
if strings.Contains(err.Error(), "status 403") {
|
||||
log.Printf("[GeminiOAuth] Drive API scope not available (403): %v", err)
|
||||
return TierGoogleOneUnknown, nil, err
|
||||
return GeminiTierGoogleOneUnknown, nil, err
|
||||
}
|
||||
// Other errors
|
||||
log.Printf("[GeminiOAuth] Failed to fetch Drive storage: %v", err)
|
||||
return TierGoogleOneUnknown, nil, err
|
||||
return GeminiTierGoogleOneUnknown, nil, err
|
||||
}
|
||||
|
||||
log.Printf("[GeminiOAuth] Drive API response - Limit: %d bytes (%.2f TB), Usage: %d bytes (%.2f GB)",
|
||||
@@ -362,11 +442,16 @@ func (s *GeminiOAuthService) RefreshAccountGoogleOneTier(
|
||||
}
|
||||
|
||||
func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExchangeCodeInput) (*GeminiTokenInfo, error) {
|
||||
log.Printf("[GeminiOAuth] ========== ExchangeCode START ==========")
|
||||
log.Printf("[GeminiOAuth] SessionID: %s", input.SessionID)
|
||||
|
||||
session, ok := s.sessionStore.Get(input.SessionID)
|
||||
if !ok {
|
||||
log.Printf("[GeminiOAuth] ERROR: Session not found or expired")
|
||||
return nil, fmt.Errorf("session not found or expired")
|
||||
}
|
||||
if strings.TrimSpace(input.State) == "" || input.State != session.State {
|
||||
log.Printf("[GeminiOAuth] ERROR: Invalid state")
|
||||
return nil, fmt.Errorf("invalid state")
|
||||
}
|
||||
|
||||
@@ -377,6 +462,7 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
log.Printf("[GeminiOAuth] ProxyURL: %s", proxyURL)
|
||||
|
||||
redirectURI := session.RedirectURI
|
||||
|
||||
@@ -385,6 +471,8 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
if oauthType == "" {
|
||||
oauthType = "code_assist"
|
||||
}
|
||||
log.Printf("[GeminiOAuth] OAuth Type: %s", oauthType)
|
||||
log.Printf("[GeminiOAuth] Project ID from session: %s", session.ProjectID)
|
||||
|
||||
// If the session was created for AI Studio OAuth, ensure a custom OAuth client is configured.
|
||||
if oauthType == "ai_studio" {
|
||||
@@ -410,8 +498,13 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
|
||||
tokenResp, err := s.oauthClient.ExchangeCode(ctx, oauthType, input.Code, session.CodeVerifier, redirectURI, proxyURL)
|
||||
if err != nil {
|
||||
log.Printf("[GeminiOAuth] ERROR: Failed to exchange code: %v", err)
|
||||
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
log.Printf("[GeminiOAuth] Token exchange successful")
|
||||
log.Printf("[GeminiOAuth] Token scope: %s", tokenResp.Scope)
|
||||
log.Printf("[GeminiOAuth] Token expires_in: %d seconds", tokenResp.ExpiresIn)
|
||||
|
||||
sessionProjectID := strings.TrimSpace(session.ProjectID)
|
||||
s.sessionStore.Delete(input.SessionID)
|
||||
|
||||
@@ -427,36 +520,63 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
|
||||
projectID := sessionProjectID
|
||||
var tierID string
|
||||
fallbackTierID := canonicalGeminiTierIDForOAuthType(oauthType, input.TierID)
|
||||
if fallbackTierID == "" {
|
||||
fallbackTierID = canonicalGeminiTierIDForOAuthType(oauthType, session.TierID)
|
||||
}
|
||||
|
||||
log.Printf("[GeminiOAuth] ========== Account Type Detection START ==========")
|
||||
log.Printf("[GeminiOAuth] OAuth Type: %s", oauthType)
|
||||
|
||||
// 对于 code_assist 模式,project_id 是必需的,需要调用 Code Assist API
|
||||
// 对于 google_one 模式,使用个人 Google 账号,不需要 project_id,配额由 Google 网关自动识别
|
||||
// 对于 ai_studio 模式,project_id 是可选的(不影响使用 AI Studio API)
|
||||
switch oauthType {
|
||||
case "code_assist":
|
||||
log.Printf("[GeminiOAuth] Processing code_assist OAuth type")
|
||||
if projectID == "" {
|
||||
log.Printf("[GeminiOAuth] No project_id provided, attempting to fetch from LoadCodeAssist API...")
|
||||
var err error
|
||||
projectID, tierID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
|
||||
if err != nil {
|
||||
// 记录警告但不阻断流程,允许后续补充 project_id
|
||||
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch project_id during token exchange: %v\n", err)
|
||||
log.Printf("[GeminiOAuth] WARNING: Failed to fetch project_id: %v", err)
|
||||
} else {
|
||||
log.Printf("[GeminiOAuth] Successfully fetched project_id: %s, tier_id: %s", projectID, tierID)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[GeminiOAuth] User provided project_id: %s, fetching tier_id...", projectID)
|
||||
// 用户手动填了 project_id,仍需调用 LoadCodeAssist 获取 tierID
|
||||
_, fetchedTierID, err := s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
|
||||
if err != nil {
|
||||
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch tierID: %v\n", err)
|
||||
log.Printf("[GeminiOAuth] WARNING: Failed to fetch tier_id: %v", err)
|
||||
} else {
|
||||
tierID = fetchedTierID
|
||||
log.Printf("[GeminiOAuth] Successfully fetched tier_id: %s", tierID)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
log.Printf("[GeminiOAuth] ERROR: Missing project_id for Code Assist OAuth")
|
||||
return nil, fmt.Errorf("missing project_id for Code Assist OAuth: please fill Project ID (optional field) and regenerate the auth URL, or ensure your Google account has an ACTIVE GCP project")
|
||||
}
|
||||
// tierID 缺失时使用默认值
|
||||
// Prefer auto-detected tier; fall back to user-selected tier.
|
||||
tierID = canonicalGeminiTierIDForOAuthType(oauthType, tierID)
|
||||
if tierID == "" {
|
||||
tierID = "LEGACY"
|
||||
if fallbackTierID != "" {
|
||||
tierID = fallbackTierID
|
||||
log.Printf("[GeminiOAuth] Using fallback tier_id from user/session: %s", tierID)
|
||||
} else {
|
||||
tierID = GeminiTierGCPStandard
|
||||
log.Printf("[GeminiOAuth] Using default tier_id: %s", tierID)
|
||||
}
|
||||
}
|
||||
log.Printf("[GeminiOAuth] Final code_assist result - project_id: %s, tier_id: %s", projectID, tierID)
|
||||
|
||||
case "google_one":
|
||||
log.Printf("[GeminiOAuth] Processing google_one OAuth type")
|
||||
log.Printf("[GeminiOAuth] Attempting to fetch Google One tier from Drive API...")
|
||||
// Attempt to fetch Drive storage tier
|
||||
var storageInfo *geminicli.DriveStorageInfo
|
||||
var err error
|
||||
@@ -464,9 +584,27 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
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
|
||||
log.Printf("[GeminiOAuth] WARNING: Failed to fetch Drive tier: %v", err)
|
||||
tierID = ""
|
||||
} else {
|
||||
log.Printf("[GeminiOAuth] Successfully fetched Drive tier: %s", tierID)
|
||||
if storageInfo != nil {
|
||||
log.Printf("[GeminiOAuth] Drive storage - Limit: %d bytes (%.2f TB), Usage: %d bytes (%.2f GB)",
|
||||
storageInfo.Limit, float64(storageInfo.Limit)/float64(TB),
|
||||
storageInfo.Usage, float64(storageInfo.Usage)/float64(GB))
|
||||
}
|
||||
}
|
||||
fmt.Printf("[GeminiOAuth] Google One tierID after fetch: %s\n", tierID)
|
||||
tierID = canonicalGeminiTierIDForOAuthType(oauthType, tierID)
|
||||
if tierID == "" || tierID == GeminiTierGoogleOneUnknown {
|
||||
if fallbackTierID != "" {
|
||||
tierID = fallbackTierID
|
||||
log.Printf("[GeminiOAuth] Using fallback tier_id from user/session: %s", tierID)
|
||||
} else {
|
||||
tierID = GeminiTierGoogleOneFree
|
||||
log.Printf("[GeminiOAuth] Using default tier_id: %s", tierID)
|
||||
}
|
||||
}
|
||||
fmt.Printf("[GeminiOAuth] Google One tierID after normalization: %s\n", tierID)
|
||||
|
||||
// Store Drive info in extra field for caching
|
||||
if storageInfo != nil {
|
||||
@@ -486,10 +624,23 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
"drive_tier_updated_at": time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
log.Printf("[GeminiOAuth] ========== ExchangeCode END (google_one with storage info) ==========")
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
case "ai_studio":
|
||||
// No automatic tier detection for AI Studio OAuth; rely on user selection.
|
||||
if fallbackTierID != "" {
|
||||
tierID = fallbackTierID
|
||||
} else {
|
||||
tierID = GeminiTierAIStudioFree
|
||||
}
|
||||
|
||||
default:
|
||||
log.Printf("[GeminiOAuth] Processing %s OAuth type (no tier detection)", oauthType)
|
||||
}
|
||||
// ai_studio 模式不设置 tierID,保持为空
|
||||
|
||||
log.Printf("[GeminiOAuth] ========== Account Type Detection END ==========")
|
||||
|
||||
result := &GeminiTokenInfo{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
@@ -502,7 +653,8 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
||||
TierID: tierID,
|
||||
OAuthType: oauthType,
|
||||
}
|
||||
fmt.Printf("[GeminiOAuth] ExchangeCode returning tierID: %s\n", result.TierID)
|
||||
log.Printf("[GeminiOAuth] Final result - OAuth Type: %s, Project ID: %s, Tier ID: %s", result.OAuthType, result.ProjectID, result.TierID)
|
||||
log.Printf("[GeminiOAuth] ========== ExchangeCode END ==========")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -599,6 +751,17 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
// Backward compatibility for google_one:
|
||||
// - New behavior: when a custom OAuth client is configured, google_one will use it.
|
||||
// - Old behavior: google_one always used the built-in Gemini CLI OAuth client.
|
||||
// If an existing account was authorized with the built-in client, refreshing with the custom client
|
||||
// will fail with "unauthorized_client". Retry with the built-in client (code_assist path forces it).
|
||||
if err != nil && oauthType == "google_one" && strings.Contains(err.Error(), "unauthorized_client") && s.GetOAuthConfig().AIStudioOAuthEnabled {
|
||||
if alt, altErr := s.RefreshToken(ctx, "code_assist", refreshToken, proxyURL); altErr == nil {
|
||||
tokenInfo = alt
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// Provide a more actionable error for common OAuth client mismatch issues.
|
||||
if strings.Contains(err.Error(), "unauthorized_client") {
|
||||
@@ -624,13 +787,14 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
|
||||
case "code_assist":
|
||||
// 先设置默认值或保留旧值,确保 tier_id 始终有值
|
||||
if existingTierID != "" {
|
||||
tokenInfo.TierID = existingTierID
|
||||
} else {
|
||||
tokenInfo.TierID = "LEGACY" // 默认值
|
||||
tokenInfo.TierID = canonicalGeminiTierIDForOAuthType(oauthType, existingTierID)
|
||||
}
|
||||
if tokenInfo.TierID == "" {
|
||||
tokenInfo.TierID = GeminiTierGCPStandard
|
||||
}
|
||||
|
||||
// 尝试自动探测 project_id 和 tier_id
|
||||
needDetect := strings.TrimSpace(tokenInfo.ProjectID) == "" || existingTierID == ""
|
||||
needDetect := strings.TrimSpace(tokenInfo.ProjectID) == "" || tokenInfo.TierID == ""
|
||||
if needDetect {
|
||||
projectID, tierID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL)
|
||||
if err != nil {
|
||||
@@ -639,9 +803,10 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
|
||||
if strings.TrimSpace(tokenInfo.ProjectID) == "" && projectID != "" {
|
||||
tokenInfo.ProjectID = projectID
|
||||
}
|
||||
// 只有当原来没有 tier_id 且探测成功时才更新
|
||||
if existingTierID == "" && tierID != "" {
|
||||
tokenInfo.TierID = tierID
|
||||
if tierID != "" {
|
||||
if canonical := canonicalGeminiTierIDForOAuthType(oauthType, tierID); canonical != "" {
|
||||
tokenInfo.TierID = canonical
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -650,6 +815,7 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
|
||||
return nil, fmt.Errorf("failed to auto-detect project_id: empty result")
|
||||
}
|
||||
case "google_one":
|
||||
canonicalExistingTier := canonicalGeminiTierIDForOAuthType(oauthType, existingTierID)
|
||||
// Check if tier cache is stale (> 24 hours)
|
||||
needsRefresh := true
|
||||
if account.Extra != nil {
|
||||
@@ -658,30 +824,37 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
|
||||
if time.Since(updatedAt) <= 24*time.Hour {
|
||||
needsRefresh = false
|
||||
// Use cached tier
|
||||
if existingTierID != "" {
|
||||
tokenInfo.TierID = existingTierID
|
||||
}
|
||||
tokenInfo.TierID = canonicalExistingTier
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tokenInfo.TierID == "" {
|
||||
tokenInfo.TierID = canonicalExistingTier
|
||||
}
|
||||
|
||||
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),
|
||||
if err == nil {
|
||||
if canonical := canonicalGeminiTierIDForOAuthType(oauthType, tierID); canonical != "" && canonical != GeminiTierGoogleOneUnknown {
|
||||
tokenInfo.TierID = canonical
|
||||
}
|
||||
if storageInfo != nil {
|
||||
tokenInfo.Extra = map[string]any{
|
||||
"drive_storage_limit": storageInfo.Limit,
|
||||
"drive_storage_usage": storageInfo.Usage,
|
||||
"drive_tier_updated_at": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tokenInfo.TierID == "" || tokenInfo.TierID == GeminiTierGoogleOneUnknown {
|
||||
if canonicalExistingTier != "" {
|
||||
tokenInfo.TierID = canonicalExistingTier
|
||||
} else {
|
||||
// Fallback to cached or unknown
|
||||
if existingTierID != "" {
|
||||
tokenInfo.TierID = existingTierID
|
||||
} else {
|
||||
tokenInfo.TierID = TierGoogleOneUnknown
|
||||
}
|
||||
tokenInfo.TierID = GeminiTierGoogleOneFree
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,129 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
func TestInferGoogleOneTier(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
storageBytes int64
|
||||
expectedTier string
|
||||
}{
|
||||
{"Negative storage", -1, TierGoogleOneUnknown},
|
||||
{"Zero storage", 0, TierGoogleOneUnknown},
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
)
|
||||
|
||||
// Free tier boundary (15GB)
|
||||
{"Below free tier", 10 * GB, TierGoogleOneUnknown},
|
||||
{"Just below free tier", StorageTierFree - 1, TierGoogleOneUnknown},
|
||||
{"Free tier (15GB)", StorageTierFree, TierFree},
|
||||
func TestGeminiOAuthService_GenerateAuthURL_RedirectURIStrategy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Basic tier boundary (100GB)
|
||||
{"Between free and basic", 50 * GB, TierFree},
|
||||
{"Just below basic tier", StorageTierBasic - 1, TierFree},
|
||||
{"Basic tier (100GB)", StorageTierBasic, TierGoogleOneBasic},
|
||||
type testCase struct {
|
||||
name string
|
||||
cfg *config.Config
|
||||
oauthType string
|
||||
projectID string
|
||||
wantClientID string
|
||||
wantRedirect string
|
||||
wantScope string
|
||||
wantProjectID string
|
||||
wantErrSubstr string
|
||||
}
|
||||
|
||||
// Standard tier boundary (200GB)
|
||||
{"Between basic and standard", 150 * GB, TierGoogleOneBasic},
|
||||
{"Just below standard tier", StorageTierStandard - 1, TierGoogleOneBasic},
|
||||
{"Standard tier (200GB)", StorageTierStandard, TierGoogleOneStandard},
|
||||
|
||||
// AI Premium tier boundary (2TB)
|
||||
{"Between standard and premium", 1 * TB, TierGoogleOneStandard},
|
||||
{"Just below AI Premium tier", StorageTierAIPremium - 1, TierGoogleOneStandard},
|
||||
{"AI Premium tier (2TB)", StorageTierAIPremium, TierAIPremium},
|
||||
|
||||
// Unlimited tier boundary (> 100TB)
|
||||
{"Between premium and unlimited", 50 * TB, TierAIPremium},
|
||||
{"At unlimited threshold (100TB)", StorageTierUnlimited, TierAIPremium},
|
||||
{"Unlimited tier (100TB+)", StorageTierUnlimited + 1, TierGoogleOneUnlimited},
|
||||
{"Unlimited tier (101TB+)", 101 * TB, TierGoogleOneUnlimited},
|
||||
{"Very large storage", 1000 * TB, TierGoogleOneUnlimited},
|
||||
tests := []testCase{
|
||||
{
|
||||
name: "google_one uses built-in client when not configured and redirects to upstream",
|
||||
cfg: &config.Config{
|
||||
Gemini: config.GeminiConfig{
|
||||
OAuth: config.GeminiOAuthConfig{},
|
||||
},
|
||||
},
|
||||
oauthType: "google_one",
|
||||
wantClientID: geminicli.GeminiCLIOAuthClientID,
|
||||
wantRedirect: geminicli.GeminiCLIRedirectURI,
|
||||
wantScope: geminicli.DefaultCodeAssistScopes,
|
||||
wantProjectID: "",
|
||||
},
|
||||
{
|
||||
name: "google_one uses custom client when configured and redirects to localhost",
|
||||
cfg: &config.Config{
|
||||
Gemini: config.GeminiConfig{
|
||||
OAuth: config.GeminiOAuthConfig{
|
||||
ClientID: "custom-client-id",
|
||||
ClientSecret: "custom-client-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
oauthType: "google_one",
|
||||
wantClientID: "custom-client-id",
|
||||
wantRedirect: geminicli.AIStudioOAuthRedirectURI,
|
||||
wantScope: geminicli.DefaultGoogleOneScopes,
|
||||
wantProjectID: "",
|
||||
},
|
||||
{
|
||||
name: "code_assist always forces built-in client even when custom client configured",
|
||||
cfg: &config.Config{
|
||||
Gemini: config.GeminiConfig{
|
||||
OAuth: config.GeminiOAuthConfig{
|
||||
ClientID: "custom-client-id",
|
||||
ClientSecret: "custom-client-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
oauthType: "code_assist",
|
||||
projectID: "my-gcp-project",
|
||||
wantClientID: geminicli.GeminiCLIOAuthClientID,
|
||||
wantRedirect: geminicli.GeminiCLIRedirectURI,
|
||||
wantScope: geminicli.DefaultCodeAssistScopes,
|
||||
wantProjectID: "my-gcp-project",
|
||||
},
|
||||
{
|
||||
name: "ai_studio requires custom client",
|
||||
cfg: &config.Config{
|
||||
Gemini: config.GeminiConfig{
|
||||
OAuth: config.GeminiOAuthConfig{},
|
||||
},
|
||||
},
|
||||
oauthType: "ai_studio",
|
||||
wantErrSubstr: "AI Studio OAuth requires a custom OAuth Client",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := inferGoogleOneTier(tt.storageBytes)
|
||||
if result != tt.expectedTier {
|
||||
t.Errorf("inferGoogleOneTier(%d) = %s, want %s",
|
||||
tt.storageBytes, result, tt.expectedTier)
|
||||
t.Parallel()
|
||||
|
||||
svc := NewGeminiOAuthService(nil, nil, nil, tt.cfg)
|
||||
got, err := svc.GenerateAuthURL(context.Background(), nil, "https://example.com/auth/callback", tt.projectID, tt.oauthType, "")
|
||||
if tt.wantErrSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErrSubstr) {
|
||||
t.Fatalf("expected error containing %q, got: %v", tt.wantErrSubstr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAuthURL returned error: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(got.AuthURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse auth_url: %v", err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
|
||||
if gotState := q.Get("state"); gotState != got.State {
|
||||
t.Fatalf("state mismatch: query=%q result=%q", gotState, got.State)
|
||||
}
|
||||
if gotClientID := q.Get("client_id"); gotClientID != tt.wantClientID {
|
||||
t.Fatalf("client_id mismatch: got=%q want=%q", gotClientID, tt.wantClientID)
|
||||
}
|
||||
if gotRedirect := q.Get("redirect_uri"); gotRedirect != tt.wantRedirect {
|
||||
t.Fatalf("redirect_uri mismatch: got=%q want=%q", gotRedirect, tt.wantRedirect)
|
||||
}
|
||||
if gotScope := q.Get("scope"); gotScope != tt.wantScope {
|
||||
t.Fatalf("scope mismatch: got=%q want=%q", gotScope, tt.wantScope)
|
||||
}
|
||||
if gotProjectID := q.Get("project_id"); gotProjectID != tt.wantProjectID {
|
||||
t.Fatalf("project_id mismatch: got=%q want=%q", gotProjectID, tt.wantProjectID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,13 +20,24 @@ const (
|
||||
geminiModelFlash geminiModelClass = "flash"
|
||||
)
|
||||
|
||||
type GeminiDailyQuota struct {
|
||||
ProRPD int64
|
||||
FlashRPD int64
|
||||
type GeminiQuota struct {
|
||||
// SharedRPD is a shared requests-per-day pool across models.
|
||||
// When SharedRPD > 0, callers should treat ProRPD/FlashRPD as not applicable for daily quota checks.
|
||||
SharedRPD int64 `json:"shared_rpd,omitempty"`
|
||||
// SharedRPM is a shared requests-per-minute pool across models.
|
||||
// When SharedRPM > 0, callers should treat ProRPM/FlashRPM as not applicable for minute quota checks.
|
||||
SharedRPM int64 `json:"shared_rpm,omitempty"`
|
||||
|
||||
// Per-model quotas (AI Studio / API key).
|
||||
// A value of -1 means "unlimited" (pay-as-you-go).
|
||||
ProRPD int64 `json:"pro_rpd,omitempty"`
|
||||
ProRPM int64 `json:"pro_rpm,omitempty"`
|
||||
FlashRPD int64 `json:"flash_rpd,omitempty"`
|
||||
FlashRPM int64 `json:"flash_rpm,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiTierPolicy struct {
|
||||
Quota GeminiDailyQuota
|
||||
Quota GeminiQuota
|
||||
Cooldown time.Duration
|
||||
}
|
||||
|
||||
@@ -45,10 +56,27 @@ type GeminiUsageTotals struct {
|
||||
|
||||
const geminiQuotaCacheTTL = time.Minute
|
||||
|
||||
type geminiQuotaOverrides struct {
|
||||
type geminiQuotaOverridesV1 struct {
|
||||
Tiers map[string]config.GeminiTierQuotaConfig `json:"tiers"`
|
||||
}
|
||||
|
||||
type geminiQuotaOverridesV2 struct {
|
||||
QuotaRules map[string]geminiQuotaRuleOverride `json:"quota_rules"`
|
||||
}
|
||||
|
||||
type geminiQuotaRuleOverride struct {
|
||||
SharedRPD *int64 `json:"shared_rpd,omitempty"`
|
||||
SharedRPM *int64 `json:"rpm,omitempty"`
|
||||
GeminiPro *geminiModelQuotaOverride `json:"gemini_pro,omitempty"`
|
||||
GeminiFlash *geminiModelQuotaOverride `json:"gemini_flash,omitempty"`
|
||||
Desc *string `json:"desc,omitempty"`
|
||||
}
|
||||
|
||||
type geminiModelQuotaOverride struct {
|
||||
RPD *int64 `json:"rpd,omitempty"`
|
||||
RPM *int64 `json:"rpm,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiQuotaService struct {
|
||||
cfg *config.Config
|
||||
settingRepo SettingRepository
|
||||
@@ -82,11 +110,17 @@ func (s *GeminiQuotaService) Policy(ctx context.Context) *GeminiQuotaPolicy {
|
||||
if s.cfg != nil {
|
||||
policy.ApplyOverrides(s.cfg.Gemini.Quota.Tiers)
|
||||
if strings.TrimSpace(s.cfg.Gemini.Quota.Policy) != "" {
|
||||
var overrides geminiQuotaOverrides
|
||||
if err := json.Unmarshal([]byte(s.cfg.Gemini.Quota.Policy), &overrides); err != nil {
|
||||
log.Printf("gemini quota: parse config policy failed: %v", err)
|
||||
raw := []byte(s.cfg.Gemini.Quota.Policy)
|
||||
var overridesV2 geminiQuotaOverridesV2
|
||||
if err := json.Unmarshal(raw, &overridesV2); err == nil && len(overridesV2.QuotaRules) > 0 {
|
||||
policy.ApplyQuotaRulesOverrides(overridesV2.QuotaRules)
|
||||
} else {
|
||||
policy.ApplyOverrides(overrides.Tiers)
|
||||
var overridesV1 geminiQuotaOverridesV1
|
||||
if err := json.Unmarshal(raw, &overridesV1); err != nil {
|
||||
log.Printf("gemini quota: parse config policy failed: %v", err)
|
||||
} else {
|
||||
policy.ApplyOverrides(overridesV1.Tiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,11 +130,17 @@ func (s *GeminiQuotaService) Policy(ctx context.Context) *GeminiQuotaPolicy {
|
||||
if err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||||
log.Printf("gemini quota: load setting failed: %v", err)
|
||||
} else if strings.TrimSpace(value) != "" {
|
||||
var overrides geminiQuotaOverrides
|
||||
if err := json.Unmarshal([]byte(value), &overrides); err != nil {
|
||||
log.Printf("gemini quota: parse setting failed: %v", err)
|
||||
raw := []byte(value)
|
||||
var overridesV2 geminiQuotaOverridesV2
|
||||
if err := json.Unmarshal(raw, &overridesV2); err == nil && len(overridesV2.QuotaRules) > 0 {
|
||||
policy.ApplyQuotaRulesOverrides(overridesV2.QuotaRules)
|
||||
} else {
|
||||
policy.ApplyOverrides(overrides.Tiers)
|
||||
var overridesV1 geminiQuotaOverridesV1
|
||||
if err := json.Unmarshal(raw, &overridesV1); err != nil {
|
||||
log.Printf("gemini quota: parse setting failed: %v", err)
|
||||
} else {
|
||||
policy.ApplyOverrides(overridesV1.Tiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,12 +153,20 @@ func (s *GeminiQuotaService) Policy(ctx context.Context) *GeminiQuotaPolicy {
|
||||
return policy
|
||||
}
|
||||
|
||||
func (s *GeminiQuotaService) QuotaForAccount(ctx context.Context, account *Account) (GeminiDailyQuota, bool) {
|
||||
if account == nil || !account.IsGeminiCodeAssist() {
|
||||
return GeminiDailyQuota{}, false
|
||||
func (s *GeminiQuotaService) QuotaForAccount(ctx context.Context, account *Account) (GeminiQuota, bool) {
|
||||
if account == nil || account.Platform != PlatformGemini {
|
||||
return GeminiQuota{}, false
|
||||
}
|
||||
|
||||
// Map (oauth_type + tier_id) to a canonical policy tier key.
|
||||
// This keeps the policy table stable even if upstream tier_id strings vary.
|
||||
tierKey := geminiQuotaTierKeyForAccount(account)
|
||||
if tierKey == "" {
|
||||
return GeminiQuota{}, false
|
||||
}
|
||||
|
||||
policy := s.Policy(ctx)
|
||||
return policy.QuotaForTier(account.GeminiTierID())
|
||||
return policy.QuotaForTier(tierKey)
|
||||
}
|
||||
|
||||
func (s *GeminiQuotaService) CooldownForTier(ctx context.Context, tierID string) time.Duration {
|
||||
@@ -126,12 +174,36 @@ func (s *GeminiQuotaService) CooldownForTier(ctx context.Context, tierID string)
|
||||
return policy.CooldownForTier(tierID)
|
||||
}
|
||||
|
||||
func (s *GeminiQuotaService) CooldownForAccount(ctx context.Context, account *Account) time.Duration {
|
||||
if s == nil || account == nil || account.Platform != PlatformGemini {
|
||||
return 5 * time.Minute
|
||||
}
|
||||
tierKey := geminiQuotaTierKeyForAccount(account)
|
||||
if strings.TrimSpace(tierKey) == "" {
|
||||
return 5 * time.Minute
|
||||
}
|
||||
return s.CooldownForTier(ctx, tierKey)
|
||||
}
|
||||
|
||||
func newGeminiQuotaPolicy() *GeminiQuotaPolicy {
|
||||
return &GeminiQuotaPolicy{
|
||||
tiers: map[string]GeminiTierPolicy{
|
||||
"LEGACY": {Quota: GeminiDailyQuota{ProRPD: 50, FlashRPD: 1500}, Cooldown: 30 * time.Minute},
|
||||
"PRO": {Quota: GeminiDailyQuota{ProRPD: 1500, FlashRPD: 4000}, Cooldown: 5 * time.Minute},
|
||||
"ULTRA": {Quota: GeminiDailyQuota{ProRPD: 2000, FlashRPD: 0}, Cooldown: 5 * time.Minute},
|
||||
// --- AI Studio / API Key (per-model) ---
|
||||
// aistudio_free:
|
||||
// - gemini_pro: 50 RPD / 2 RPM
|
||||
// - gemini_flash: 1500 RPD / 15 RPM
|
||||
GeminiTierAIStudioFree: {Quota: GeminiQuota{ProRPD: 50, ProRPM: 2, FlashRPD: 1500, FlashRPM: 15}, Cooldown: 30 * time.Minute},
|
||||
// aistudio_paid: -1 means "unlimited/pay-as-you-go" for RPD.
|
||||
GeminiTierAIStudioPaid: {Quota: GeminiQuota{ProRPD: -1, ProRPM: 1000, FlashRPD: -1, FlashRPM: 2000}, Cooldown: 5 * time.Minute},
|
||||
|
||||
// --- Google One (shared pool) ---
|
||||
GeminiTierGoogleOneFree: {Quota: GeminiQuota{SharedRPD: 1000, SharedRPM: 60}, Cooldown: 30 * time.Minute},
|
||||
GeminiTierGoogleAIPro: {Quota: GeminiQuota{SharedRPD: 1500, SharedRPM: 120}, Cooldown: 5 * time.Minute},
|
||||
GeminiTierGoogleAIUltra: {Quota: GeminiQuota{SharedRPD: 2000, SharedRPM: 120}, Cooldown: 5 * time.Minute},
|
||||
|
||||
// --- GCP Code Assist (shared pool) ---
|
||||
GeminiTierGCPStandard: {Quota: GeminiQuota{SharedRPD: 1500, SharedRPM: 120}, Cooldown: 5 * time.Minute},
|
||||
GeminiTierGCPEnterprise: {Quota: GeminiQuota{SharedRPD: 2000, SharedRPM: 120}, Cooldown: 5 * time.Minute},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -149,11 +221,22 @@ func (p *GeminiQuotaPolicy) ApplyOverrides(tiers map[string]config.GeminiTierQuo
|
||||
if !ok {
|
||||
policy = GeminiTierPolicy{Cooldown: 5 * time.Minute}
|
||||
}
|
||||
// Backward-compatible overrides:
|
||||
// - If the tier uses shared quota, interpret pro_rpd as shared_rpd.
|
||||
// - Otherwise apply per-model overrides.
|
||||
if override.ProRPD != nil {
|
||||
policy.Quota.ProRPD = clampGeminiQuotaInt64(*override.ProRPD)
|
||||
if policy.Quota.SharedRPD > 0 {
|
||||
policy.Quota.SharedRPD = clampGeminiQuotaInt64WithUnlimited(*override.ProRPD)
|
||||
} else {
|
||||
policy.Quota.ProRPD = clampGeminiQuotaInt64WithUnlimited(*override.ProRPD)
|
||||
}
|
||||
}
|
||||
if override.FlashRPD != nil {
|
||||
policy.Quota.FlashRPD = clampGeminiQuotaInt64(*override.FlashRPD)
|
||||
if policy.Quota.SharedRPD > 0 {
|
||||
// No separate flash RPD for shared tiers.
|
||||
} else {
|
||||
policy.Quota.FlashRPD = clampGeminiQuotaInt64WithUnlimited(*override.FlashRPD)
|
||||
}
|
||||
}
|
||||
if override.CooldownMinutes != nil {
|
||||
minutes := clampGeminiQuotaInt(*override.CooldownMinutes)
|
||||
@@ -163,10 +246,51 @@ func (p *GeminiQuotaPolicy) ApplyOverrides(tiers map[string]config.GeminiTierQuo
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GeminiQuotaPolicy) QuotaForTier(tierID string) (GeminiDailyQuota, bool) {
|
||||
func (p *GeminiQuotaPolicy) ApplyQuotaRulesOverrides(rules map[string]geminiQuotaRuleOverride) {
|
||||
if p == nil || len(rules) == 0 {
|
||||
return
|
||||
}
|
||||
for rawID, override := range rules {
|
||||
tierID := normalizeGeminiTierID(rawID)
|
||||
if tierID == "" {
|
||||
continue
|
||||
}
|
||||
policy, ok := p.tiers[tierID]
|
||||
if !ok {
|
||||
policy = GeminiTierPolicy{Cooldown: 5 * time.Minute}
|
||||
}
|
||||
|
||||
if override.SharedRPD != nil {
|
||||
policy.Quota.SharedRPD = clampGeminiQuotaInt64WithUnlimited(*override.SharedRPD)
|
||||
}
|
||||
if override.SharedRPM != nil {
|
||||
policy.Quota.SharedRPM = clampGeminiQuotaRPM(*override.SharedRPM)
|
||||
}
|
||||
if override.GeminiPro != nil {
|
||||
if override.GeminiPro.RPD != nil {
|
||||
policy.Quota.ProRPD = clampGeminiQuotaInt64WithUnlimited(*override.GeminiPro.RPD)
|
||||
}
|
||||
if override.GeminiPro.RPM != nil {
|
||||
policy.Quota.ProRPM = clampGeminiQuotaRPM(*override.GeminiPro.RPM)
|
||||
}
|
||||
}
|
||||
if override.GeminiFlash != nil {
|
||||
if override.GeminiFlash.RPD != nil {
|
||||
policy.Quota.FlashRPD = clampGeminiQuotaInt64WithUnlimited(*override.GeminiFlash.RPD)
|
||||
}
|
||||
if override.GeminiFlash.RPM != nil {
|
||||
policy.Quota.FlashRPM = clampGeminiQuotaRPM(*override.GeminiFlash.RPM)
|
||||
}
|
||||
}
|
||||
|
||||
p.tiers[tierID] = policy
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GeminiQuotaPolicy) QuotaForTier(tierID string) (GeminiQuota, bool) {
|
||||
policy, ok := p.policyForTier(tierID)
|
||||
if !ok {
|
||||
return GeminiDailyQuota{}, false
|
||||
return GeminiQuota{}, false
|
||||
}
|
||||
return policy.Quota, true
|
||||
}
|
||||
@@ -184,22 +308,43 @@ func (p *GeminiQuotaPolicy) policyForTier(tierID string) (GeminiTierPolicy, bool
|
||||
return GeminiTierPolicy{}, false
|
||||
}
|
||||
normalized := normalizeGeminiTierID(tierID)
|
||||
if normalized == "" {
|
||||
normalized = "LEGACY"
|
||||
}
|
||||
if policy, ok := p.tiers[normalized]; ok {
|
||||
return policy, true
|
||||
}
|
||||
policy, ok := p.tiers["LEGACY"]
|
||||
return policy, ok
|
||||
return GeminiTierPolicy{}, false
|
||||
}
|
||||
|
||||
func normalizeGeminiTierID(tierID string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(tierID))
|
||||
tierID = strings.TrimSpace(tierID)
|
||||
if tierID == "" {
|
||||
return ""
|
||||
}
|
||||
// Prefer canonical mapping (handles legacy tier strings).
|
||||
if canonical := canonicalGeminiTierID(tierID); canonical != "" {
|
||||
return canonical
|
||||
}
|
||||
// Accept older policy keys that used uppercase names.
|
||||
switch strings.ToUpper(tierID) {
|
||||
case "AISTUDIO_FREE":
|
||||
return GeminiTierAIStudioFree
|
||||
case "AISTUDIO_PAID":
|
||||
return GeminiTierAIStudioPaid
|
||||
case "GOOGLE_ONE_FREE":
|
||||
return GeminiTierGoogleOneFree
|
||||
case "GOOGLE_AI_PRO":
|
||||
return GeminiTierGoogleAIPro
|
||||
case "GOOGLE_AI_ULTRA":
|
||||
return GeminiTierGoogleAIUltra
|
||||
case "GCP_STANDARD":
|
||||
return GeminiTierGCPStandard
|
||||
case "GCP_ENTERPRISE":
|
||||
return GeminiTierGCPEnterprise
|
||||
}
|
||||
return strings.ToLower(tierID)
|
||||
}
|
||||
|
||||
func clampGeminiQuotaInt64(value int64) int64 {
|
||||
if value < 0 {
|
||||
func clampGeminiQuotaInt64WithUnlimited(value int64) int64 {
|
||||
if value < -1 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
@@ -212,11 +357,46 @@ func clampGeminiQuotaInt(value int) int {
|
||||
return value
|
||||
}
|
||||
|
||||
func clampGeminiQuotaRPM(value int64) int64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func geminiCooldownForTier(tierID string) time.Duration {
|
||||
policy := newGeminiQuotaPolicy()
|
||||
return policy.CooldownForTier(tierID)
|
||||
}
|
||||
|
||||
func geminiQuotaTierKeyForAccount(account *Account) string {
|
||||
if account == nil || account.Platform != PlatformGemini {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Note: GeminiOAuthType() already defaults legacy (project_id present) to code_assist.
|
||||
oauthType := strings.ToLower(strings.TrimSpace(account.GeminiOAuthType()))
|
||||
rawTier := strings.TrimSpace(account.GeminiTierID())
|
||||
|
||||
// Prefer the canonical tier stored in credentials.
|
||||
if tierID := canonicalGeminiTierIDForOAuthType(oauthType, rawTier); tierID != "" && tierID != GeminiTierGoogleOneUnknown {
|
||||
return tierID
|
||||
}
|
||||
|
||||
// Fallback defaults when tier_id is missing or unknown.
|
||||
switch oauthType {
|
||||
case "google_one":
|
||||
return GeminiTierGoogleOneFree
|
||||
case "code_assist":
|
||||
return GeminiTierGCPStandard
|
||||
case "ai_studio":
|
||||
return GeminiTierAIStudioFree
|
||||
default:
|
||||
// API Key accounts (type=apikey) have empty oauth_type and are treated as AI Studio.
|
||||
return GeminiTierAIStudioFree
|
||||
}
|
||||
}
|
||||
|
||||
func geminiModelClassFromName(model string) geminiModelClass {
|
||||
name := strings.ToLower(strings.TrimSpace(model))
|
||||
if strings.Contains(name, "flash") || strings.Contains(name, "lite") {
|
||||
|
||||
@@ -92,7 +92,7 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
|
||||
// PreCheckUsage proactively checks local quota before dispatching a request.
|
||||
// Returns false when the account should be skipped.
|
||||
func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account, requestedModel string) (bool, error) {
|
||||
if account == nil || !account.IsGeminiCodeAssist() || strings.TrimSpace(requestedModel) == "" {
|
||||
if account == nil || account.Platform != PlatformGemini {
|
||||
return true, nil
|
||||
}
|
||||
if s.usageRepo == nil || s.geminiQuotaService == nil {
|
||||
@@ -104,44 +104,99 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account,
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var limit int64
|
||||
switch geminiModelClassFromName(requestedModel) {
|
||||
case geminiModelFlash:
|
||||
limit = quota.FlashRPD
|
||||
default:
|
||||
limit = quota.ProRPD
|
||||
}
|
||||
if limit <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
start := geminiDailyWindowStart(now)
|
||||
totals, ok := s.getGeminiUsageTotals(account.ID, start, now)
|
||||
if !ok {
|
||||
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
|
||||
if err != nil {
|
||||
return true, err
|
||||
modelClass := geminiModelClassFromName(requestedModel)
|
||||
|
||||
// 1) Daily quota precheck (RPD; resets at PST midnight)
|
||||
{
|
||||
var limit int64
|
||||
if quota.SharedRPD > 0 {
|
||||
limit = quota.SharedRPD
|
||||
} else {
|
||||
switch modelClass {
|
||||
case geminiModelFlash:
|
||||
limit = quota.FlashRPD
|
||||
default:
|
||||
limit = quota.ProRPD
|
||||
}
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
start := geminiDailyWindowStart(now)
|
||||
totals, ok := s.getGeminiUsageTotals(account.ID, start, now)
|
||||
if !ok {
|
||||
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
totals = geminiAggregateUsage(stats)
|
||||
s.setGeminiUsageTotals(account.ID, start, now, totals)
|
||||
}
|
||||
|
||||
var used int64
|
||||
if quota.SharedRPD > 0 {
|
||||
used = totals.ProRequests + totals.FlashRequests
|
||||
} else {
|
||||
switch modelClass {
|
||||
case geminiModelFlash:
|
||||
used = totals.FlashRequests
|
||||
default:
|
||||
used = totals.ProRequests
|
||||
}
|
||||
}
|
||||
|
||||
if used >= limit {
|
||||
resetAt := geminiDailyResetTime(now)
|
||||
// NOTE:
|
||||
// - This is a local precheck to reduce upstream 429s.
|
||||
// - Do NOT mark the account as rate-limited here; rate_limit_reset_at should reflect real upstream 429s.
|
||||
log.Printf("[Gemini PreCheck] Account %d reached daily quota (%d/%d), skip until %v", account.ID, used, limit, resetAt)
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
totals = geminiAggregateUsage(stats)
|
||||
s.setGeminiUsageTotals(account.ID, start, now, totals)
|
||||
}
|
||||
|
||||
var used int64
|
||||
switch geminiModelClassFromName(requestedModel) {
|
||||
case geminiModelFlash:
|
||||
used = totals.FlashRequests
|
||||
default:
|
||||
used = totals.ProRequests
|
||||
}
|
||||
|
||||
if used >= limit {
|
||||
resetAt := geminiDailyResetTime(now)
|
||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
|
||||
log.Printf("SetRateLimited failed for account %d: %v", account.ID, err)
|
||||
// 2) Minute quota precheck (RPM; fixed window current minute)
|
||||
{
|
||||
var limit int64
|
||||
if quota.SharedRPM > 0 {
|
||||
limit = quota.SharedRPM
|
||||
} else {
|
||||
switch modelClass {
|
||||
case geminiModelFlash:
|
||||
limit = quota.FlashRPM
|
||||
default:
|
||||
limit = quota.ProRPM
|
||||
}
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
start := now.Truncate(time.Minute)
|
||||
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
totals := geminiAggregateUsage(stats)
|
||||
|
||||
var used int64
|
||||
if quota.SharedRPM > 0 {
|
||||
used = totals.ProRequests + totals.FlashRequests
|
||||
} else {
|
||||
switch modelClass {
|
||||
case geminiModelFlash:
|
||||
used = totals.FlashRequests
|
||||
default:
|
||||
used = totals.ProRequests
|
||||
}
|
||||
}
|
||||
|
||||
if used >= limit {
|
||||
resetAt := start.Add(time.Minute)
|
||||
// Do not persist "rate limited" status from local precheck. See note above.
|
||||
log.Printf("[Gemini PreCheck] Account %d reached minute quota (%d/%d), skip until %v", account.ID, used, limit, resetAt)
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
log.Printf("[Gemini PreCheck] Account %d reached daily quota (%d/%d), rate limited until %v", account.ID, used, limit, resetAt)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
@@ -186,7 +241,10 @@ func (s *RateLimitService) GeminiCooldown(ctx context.Context, account *Account)
|
||||
if account == nil {
|
||||
return 5 * time.Minute
|
||||
}
|
||||
return s.geminiQuotaService.CooldownForTier(ctx, account.GeminiTierID())
|
||||
if s.geminiQuotaService == nil {
|
||||
return 5 * time.Minute
|
||||
}
|
||||
return s.geminiQuotaService.CooldownForAccount(ctx, account)
|
||||
}
|
||||
|
||||
// handleAuthError 处理认证类错误(401/403),停止账号调度
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface GeminiAuthUrlRequest {
|
||||
proxy_id?: number
|
||||
project_id?: string
|
||||
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
|
||||
tier_id?: string
|
||||
}
|
||||
|
||||
export interface GeminiExchangeCodeRequest {
|
||||
@@ -28,6 +29,7 @@ export interface GeminiExchangeCodeRequest {
|
||||
code: string
|
||||
proxy_id?: number
|
||||
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
|
||||
tier_id?: string
|
||||
}
|
||||
|
||||
export type GeminiTokenInfo = {
|
||||
|
||||
@@ -65,32 +65,33 @@ const tierLabel = computed(() => {
|
||||
const creds = props.account.credentials as GeminiCredentials | undefined
|
||||
|
||||
if (isCodeAssist.value) {
|
||||
// GCP Code Assist: 显示 GCP tier
|
||||
const tierMap: Record<string, string> = {
|
||||
LEGACY: 'Free',
|
||||
PRO: 'Pro',
|
||||
ULTRA: 'Ultra',
|
||||
'standard-tier': 'Standard',
|
||||
'pro-tier': 'Pro',
|
||||
'ultra-tier': 'Ultra'
|
||||
}
|
||||
return tierMap[creds?.tier_id || ''] || (creds?.tier_id ? 'GCP' : 'Unknown')
|
||||
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
|
||||
if (tier === 'gcp_enterprise') return 'GCP Enterprise'
|
||||
if (tier === 'gcp_standard') return 'GCP Standard'
|
||||
// Backward compatibility
|
||||
const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
|
||||
if (upper.includes('ULTRA') || upper.includes('ENTERPRISE')) return 'GCP Enterprise'
|
||||
if (upper) return `GCP ${upper}`
|
||||
return 'GCP'
|
||||
}
|
||||
|
||||
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'
|
||||
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
|
||||
if (tier === 'google_ai_ultra') return 'Google AI Ultra'
|
||||
if (tier === 'google_ai_pro') return 'Google AI Pro'
|
||||
if (tier === 'google_one_free') return 'Google One Free'
|
||||
// Backward compatibility
|
||||
const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
|
||||
if (upper === 'AI_PREMIUM') return 'Google AI Pro'
|
||||
if (upper === 'GOOGLE_ONE_UNLIMITED') return 'Google AI Ultra'
|
||||
if (upper) return `Google One ${upper}`
|
||||
return 'Google One'
|
||||
}
|
||||
|
||||
// API Key: 显示 AI Studio
|
||||
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
|
||||
if (tier === 'aistudio_paid') return 'AI Studio Pay-as-you-go'
|
||||
if (tier === 'aistudio_free') return 'AI Studio Free Tier'
|
||||
return 'AI Studio'
|
||||
})
|
||||
|
||||
@@ -99,35 +100,31 @@ const tierBadgeClass = computed(() => {
|
||||
const creds = props.account.credentials as GeminiCredentials | undefined
|
||||
|
||||
if (isCodeAssist.value) {
|
||||
// GCP Code Assist 样式
|
||||
const tierColorMap: Record<string, string> = {
|
||||
LEGACY: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
|
||||
PRO: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
ULTRA: 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
'standard-tier': 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-300',
|
||||
'pro-tier': 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
'ultra-tier': 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
}
|
||||
return (
|
||||
tierColorMap[creds?.tier_id || ''] ||
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
)
|
||||
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
|
||||
if (tier === 'gcp_enterprise') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
if (tier === 'gcp_standard') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
// Backward compatibility
|
||||
const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
|
||||
if (upper.includes('ULTRA') || upper.includes('ENTERPRISE')) return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
}
|
||||
|
||||
if (isGoogleOne.value) {
|
||||
// Google One tier 样式
|
||||
const tierColorMap: 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 tierColorMap[creds?.tier_id || ''] || 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
|
||||
if (tier === 'google_ai_ultra') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
if (tier === 'google_ai_pro') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
if (tier === 'google_one_free') return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
// Backward compatibility
|
||||
const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
|
||||
if (upper === 'GOOGLE_ONE_UNLIMITED') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
if (upper === 'AI_PREMIUM') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
}
|
||||
|
||||
// AI Studio 默认样式:蓝色
|
||||
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
|
||||
if (tier === 'aistudio_paid') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
if (tier === 'aistudio_free') return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
})
|
||||
|
||||
|
||||
@@ -241,23 +241,16 @@
|
||||
<div v-else-if="error" class="text-xs text-red-500">
|
||||
{{ error }}
|
||||
</div>
|
||||
<!-- GCP & Google One: show model usage bars when available -->
|
||||
<!-- Gemini: show daily usage bars when available -->
|
||||
<div v-else-if="geminiUsageAvailable" class="space-y-1">
|
||||
<UsageProgressBar
|
||||
v-if="usageInfo?.gemini_pro_daily"
|
||||
label="Pro"
|
||||
:utilization="usageInfo.gemini_pro_daily.utilization"
|
||||
:resets-at="usageInfo.gemini_pro_daily.resets_at"
|
||||
:window-stats="usageInfo.gemini_pro_daily.window_stats"
|
||||
color="indigo"
|
||||
/>
|
||||
<UsageProgressBar
|
||||
v-if="usageInfo?.gemini_flash_daily"
|
||||
label="Flash"
|
||||
:utilization="usageInfo.gemini_flash_daily.utilization"
|
||||
:resets-at="usageInfo.gemini_flash_daily.resets_at"
|
||||
:window-stats="usageInfo.gemini_flash_daily.window_stats"
|
||||
color="emerald"
|
||||
v-for="bar in geminiUsageBars"
|
||||
:key="bar.key"
|
||||
:label="bar.label"
|
||||
:utilization="bar.utilization"
|
||||
:resets-at="bar.resetsAt"
|
||||
:window-stats="bar.windowStats"
|
||||
:color="bar.color"
|
||||
/>
|
||||
<p class="mt-1 text-[9px] leading-tight text-gray-400 dark:text-gray-500 italic">
|
||||
* {{ t('admin.accounts.gemini.quotaPolicy.simulatedNote') || 'Simulated quota' }}
|
||||
@@ -288,7 +281,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, AccountUsageInfo, GeminiCredentials } from '@/types'
|
||||
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
|
||||
@@ -303,16 +296,18 @@ const error = ref<string | null>(null)
|
||||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||
|
||||
// Show usage windows for OAuth and Setup Token accounts
|
||||
const showUsageWindows = computed(
|
||||
() => props.account.type === 'oauth' || props.account.type === 'setup-token'
|
||||
)
|
||||
const showUsageWindows = computed(() => {
|
||||
// Gemini: we can always compute local usage windows from DB logs (simulated quotas).
|
||||
if (props.account.platform === 'gemini') return true
|
||||
return props.account.type === 'oauth' || props.account.type === 'setup-token'
|
||||
})
|
||||
|
||||
const shouldFetchUsage = computed(() => {
|
||||
if (props.account.platform === 'anthropic') {
|
||||
return props.account.type === 'oauth' || props.account.type === 'setup-token'
|
||||
}
|
||||
if (props.account.platform === 'gemini') {
|
||||
return props.account.type === 'oauth'
|
||||
return true
|
||||
}
|
||||
if (props.account.platform === 'antigravity') {
|
||||
return props.account.type === 'oauth'
|
||||
@@ -322,8 +317,12 @@ const shouldFetchUsage = computed(() => {
|
||||
|
||||
const geminiUsageAvailable = computed(() => {
|
||||
return (
|
||||
!!usageInfo.value?.gemini_shared_daily ||
|
||||
!!usageInfo.value?.gemini_pro_daily ||
|
||||
!!usageInfo.value?.gemini_flash_daily
|
||||
!!usageInfo.value?.gemini_flash_daily ||
|
||||
!!usageInfo.value?.gemini_shared_minute ||
|
||||
!!usageInfo.value?.gemini_pro_minute ||
|
||||
!!usageInfo.value?.gemini_flash_minute
|
||||
)
|
||||
})
|
||||
|
||||
@@ -569,6 +568,12 @@ const geminiTier = computed(() => {
|
||||
return creds?.tier_id || null
|
||||
})
|
||||
|
||||
const geminiOAuthType = computed(() => {
|
||||
if (props.account.platform !== 'gemini') return null
|
||||
const creds = props.account.credentials as GeminiCredentials | undefined
|
||||
return (creds?.oauth_type || '').trim() || null
|
||||
})
|
||||
|
||||
// Gemini 是否为 Code Assist OAuth
|
||||
const isGeminiCodeAssist = computed(() => {
|
||||
if (props.account.platform !== 'gemini') return false
|
||||
@@ -576,109 +581,208 @@ const isGeminiCodeAssist = computed(() => {
|
||||
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
|
||||
})
|
||||
|
||||
// Gemini 认证类型 + Tier 组合标签(简洁版)
|
||||
const geminiAuthTypeLabel = computed(() => {
|
||||
const creds = props.account.credentials as GeminiCredentials | undefined
|
||||
const oauthType = creds?.oauth_type
|
||||
const geminiChannelShort = computed((): 'ai studio' | 'gcp' | 'google one' | 'client' | null => {
|
||||
if (props.account.platform !== 'gemini') return null
|
||||
|
||||
// For API Key accounts, don't show auth type label
|
||||
if (props.account.type !== 'oauth') return null
|
||||
// API Key accounts are AI Studio.
|
||||
if (props.account.type === 'apikey') return 'ai studio'
|
||||
|
||||
if (oauthType === 'google_one') {
|
||||
// Google One: show "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'
|
||||
}
|
||||
const tierLabel = geminiTier.value ? tierMap[geminiTier.value] || 'Personal' : 'Personal'
|
||||
return `Google One ${tierLabel}`
|
||||
} else if (oauthType === 'code_assist' || (!oauthType && isGeminiCodeAssist.value)) {
|
||||
// Code Assist: show "GCP" + tier
|
||||
const tierMap: Record<string, string> = {
|
||||
LEGACY: 'Free',
|
||||
PRO: 'Pro',
|
||||
ULTRA: 'Ultra'
|
||||
}
|
||||
const tierLabel = geminiTier.value ? tierMap[geminiTier.value] || 'Free' : 'Free'
|
||||
return `GCP ${tierLabel}`
|
||||
} else if (oauthType === 'ai_studio') {
|
||||
// 自定义 OAuth Client: show "Client" (no tier)
|
||||
return 'Client'
|
||||
if (geminiOAuthType.value === 'google_one') return 'google one'
|
||||
if (isGeminiCodeAssist.value) return 'gcp'
|
||||
if (geminiOAuthType.value === 'ai_studio') return 'client'
|
||||
|
||||
// Fallback (unknown legacy data): treat as AI Studio.
|
||||
return 'ai studio'
|
||||
})
|
||||
|
||||
const geminiUserLevel = computed((): string | null => {
|
||||
if (props.account.platform !== 'gemini') return null
|
||||
|
||||
const tier = (geminiTier.value || '').toString().trim()
|
||||
const tierLower = tier.toLowerCase()
|
||||
const tierUpper = tier.toUpperCase()
|
||||
|
||||
// Google One: free / pro / ultra
|
||||
if (geminiOAuthType.value === 'google_one') {
|
||||
if (tierLower === 'google_one_free') return 'free'
|
||||
if (tierLower === 'google_ai_pro') return 'pro'
|
||||
if (tierLower === 'google_ai_ultra') return 'ultra'
|
||||
|
||||
// Backward compatibility (legacy tier markers)
|
||||
if (tierUpper === 'AI_PREMIUM' || tierUpper === 'GOOGLE_ONE_STANDARD') return 'pro'
|
||||
if (tierUpper === 'GOOGLE_ONE_UNLIMITED') return 'ultra'
|
||||
if (tierUpper === 'FREE' || tierUpper === 'GOOGLE_ONE_BASIC' || tierUpper === 'GOOGLE_ONE_UNKNOWN' || tierUpper === '') return 'free'
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// GCP Code Assist: standard / enterprise
|
||||
if (isGeminiCodeAssist.value) {
|
||||
if (tierLower === 'gcp_enterprise') return 'enterprise'
|
||||
if (tierLower === 'gcp_standard') return 'standard'
|
||||
|
||||
// Backward compatibility
|
||||
if (tierUpper.includes('ULTRA') || tierUpper.includes('ENTERPRISE')) return 'enterprise'
|
||||
return 'standard'
|
||||
}
|
||||
|
||||
// AI Studio (API Key) and Client OAuth: free / paid
|
||||
if (props.account.type === 'apikey' || geminiOAuthType.value === 'ai_studio') {
|
||||
if (tierLower === 'aistudio_paid') return 'paid'
|
||||
if (tierLower === 'aistudio_free') return 'free'
|
||||
|
||||
// Backward compatibility
|
||||
if (tierUpper.includes('PAID') || tierUpper.includes('PAYG') || tierUpper.includes('PAY')) return 'paid'
|
||||
if (tierUpper.includes('FREE')) return 'free'
|
||||
if (props.account.type === 'apikey') return 'free'
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
// Gemini 认证类型(按要求:授权方式简称 + 用户等级)
|
||||
const geminiAuthTypeLabel = computed(() => {
|
||||
if (props.account.platform !== 'gemini') return null
|
||||
if (!geminiChannelShort.value) return null
|
||||
return geminiUserLevel.value ? `${geminiChannelShort.value} ${geminiUserLevel.value}` : geminiChannelShort.value
|
||||
})
|
||||
|
||||
// Gemini 账户类型徽章样式(统一样式)
|
||||
const geminiTierClass = computed(() => {
|
||||
const creds = props.account.credentials as GeminiCredentials | undefined
|
||||
const oauthType = creds?.oauth_type
|
||||
// Use channel+level to choose a stable color without depending on raw tier_id variants.
|
||||
const channel = geminiChannelShort.value
|
||||
const level = geminiUserLevel.value
|
||||
|
||||
// Client (自定义 OAuth): 使用蓝色(与 AI Studio 一致)
|
||||
if (oauthType === 'ai_studio') {
|
||||
if (channel === 'client' || channel === 'ai studio') {
|
||||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
}
|
||||
|
||||
if (!geminiTier.value) return ''
|
||||
|
||||
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'
|
||||
if (channel === 'google one') {
|
||||
if (level === 'ultra') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
if (level === 'pro') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
}
|
||||
|
||||
// Code Assist tier 颜色
|
||||
switch (geminiTier.value) {
|
||||
case 'LEGACY':
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
case 'PRO':
|
||||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
case 'ULTRA':
|
||||
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
default:
|
||||
return ''
|
||||
if (channel === 'gcp') {
|
||||
if (level === 'enterprise') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
// Gemini 配额政策信息
|
||||
const geminiQuotaPolicyChannel = computed(() => {
|
||||
if (geminiOAuthType.value === 'google_one') {
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.channel')
|
||||
}
|
||||
if (isGeminiCodeAssist.value) {
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.cli.channel')
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.gcp.channel')
|
||||
}
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.channel')
|
||||
})
|
||||
|
||||
const geminiQuotaPolicyLimits = computed(() => {
|
||||
if (isGeminiCodeAssist.value) {
|
||||
if (geminiTier.value === 'PRO' || geminiTier.value === 'ULTRA') {
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsPremium')
|
||||
const tierLower = (geminiTier.value || '').toString().trim().toLowerCase()
|
||||
|
||||
if (geminiOAuthType.value === 'google_one') {
|
||||
if (tierLower === 'google_ai_ultra' || geminiUserLevel.value === 'ultra') {
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.limitsUltra')
|
||||
}
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsFree')
|
||||
if (tierLower === 'google_ai_pro' || geminiUserLevel.value === 'pro') {
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.limitsPro')
|
||||
}
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.limitsFree')
|
||||
}
|
||||
|
||||
if (isGeminiCodeAssist.value) {
|
||||
if (tierLower === 'gcp_enterprise' || geminiUserLevel.value === 'enterprise') {
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.gcp.limitsEnterprise')
|
||||
}
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.gcp.limitsStandard')
|
||||
}
|
||||
|
||||
// AI Studio (API Key / custom OAuth)
|
||||
if (tierLower === 'aistudio_paid' || geminiUserLevel.value === 'paid') {
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsPaid')
|
||||
}
|
||||
// AI Studio - 默认显示免费层限制
|
||||
return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsFree')
|
||||
})
|
||||
|
||||
const geminiQuotaPolicyDocsUrl = computed(() => {
|
||||
if (isGeminiCodeAssist.value) {
|
||||
return 'https://cloud.google.com/products/gemini/code-assist#pricing'
|
||||
if (geminiOAuthType.value === 'google_one' || isGeminiCodeAssist.value) {
|
||||
return 'https://developers.google.com/gemini-code-assist/resources/quotas'
|
||||
}
|
||||
return 'https://ai.google.dev/pricing'
|
||||
})
|
||||
|
||||
const geminiUsesSharedDaily = computed(() => {
|
||||
if (props.account.platform !== 'gemini') return false
|
||||
// Per requirement: Google One & GCP are shared RPD pools (no per-model breakdown).
|
||||
return (
|
||||
!!usageInfo.value?.gemini_shared_daily ||
|
||||
!!usageInfo.value?.gemini_shared_minute ||
|
||||
geminiOAuthType.value === 'google_one' ||
|
||||
isGeminiCodeAssist.value
|
||||
)
|
||||
})
|
||||
|
||||
const geminiUsageBars = computed(() => {
|
||||
if (props.account.platform !== 'gemini') return []
|
||||
if (!usageInfo.value) return []
|
||||
|
||||
const bars: Array<{
|
||||
key: string
|
||||
label: string
|
||||
utilization: number
|
||||
resetsAt: string | null
|
||||
windowStats?: WindowStats | null
|
||||
color: 'indigo' | 'emerald'
|
||||
}> = []
|
||||
|
||||
if (geminiUsesSharedDaily.value) {
|
||||
const sharedDaily = usageInfo.value.gemini_shared_daily
|
||||
if (sharedDaily) {
|
||||
bars.push({
|
||||
key: 'shared_daily',
|
||||
label: '1d',
|
||||
utilization: sharedDaily.utilization,
|
||||
resetsAt: sharedDaily.resets_at,
|
||||
windowStats: sharedDaily.window_stats,
|
||||
color: 'indigo'
|
||||
})
|
||||
}
|
||||
return bars
|
||||
}
|
||||
|
||||
const pro = usageInfo.value.gemini_pro_daily
|
||||
if (pro) {
|
||||
bars.push({
|
||||
key: 'pro_daily',
|
||||
label: 'pro',
|
||||
utilization: pro.utilization,
|
||||
resetsAt: pro.resets_at,
|
||||
windowStats: pro.window_stats,
|
||||
color: 'indigo'
|
||||
})
|
||||
}
|
||||
|
||||
const flash = usageInfo.value.gemini_flash_daily
|
||||
if (flash) {
|
||||
bars.push({
|
||||
key: 'flash_daily',
|
||||
label: 'flash',
|
||||
utilization: flash.utilization,
|
||||
resetsAt: flash.resets_at,
|
||||
windowStats: flash.window_stats,
|
||||
color: 'emerald'
|
||||
})
|
||||
}
|
||||
|
||||
return bars
|
||||
})
|
||||
|
||||
// 账户类型显示标签
|
||||
const antigravityTierLabel = computed(() => {
|
||||
switch (antigravityTier.value) {
|
||||
|
||||
@@ -653,6 +653,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tier selection (used as fallback when auto-detection is unavailable/fails) -->
|
||||
<div class="mt-4">
|
||||
<label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label>
|
||||
<div class="mt-2">
|
||||
<select
|
||||
v-if="geminiOAuthType === 'google_one'"
|
||||
v-model="geminiTierGoogleOne"
|
||||
class="input"
|
||||
>
|
||||
<option value="google_one_free">{{ t('admin.accounts.gemini.tier.googleOne.free') }}</option>
|
||||
<option value="google_ai_pro">{{ t('admin.accounts.gemini.tier.googleOne.pro') }}</option>
|
||||
<option value="google_ai_ultra">{{ t('admin.accounts.gemini.tier.googleOne.ultra') }}</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
v-else-if="geminiOAuthType === 'code_assist'"
|
||||
v-model="geminiTierGcp"
|
||||
class="input"
|
||||
>
|
||||
<option value="gcp_standard">{{ t('admin.accounts.gemini.tier.gcp.standard') }}</option>
|
||||
<option value="gcp_enterprise">{{ t('admin.accounts.gemini.tier.gcp.enterprise') }}</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
v-else
|
||||
v-model="geminiTierAIStudio"
|
||||
class="input"
|
||||
>
|
||||
<option value="aistudio_free">{{ t('admin.accounts.gemini.tier.aiStudio.free') }}</option>
|
||||
<option value="aistudio_paid">{{ t('admin.accounts.gemini.tier.aiStudio.paid') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.gemini.tier.hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-4 text-xs text-blue-900 dark:border-blue-800/40 dark:bg-blue-900/20 dark:text-blue-200">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg
|
||||
@@ -820,6 +855,16 @@
|
||||
<p class="input-hint">{{ apiKeyHint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Gemini API Key tier selection -->
|
||||
<div v-if="form.platform === 'gemini'">
|
||||
<label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label>
|
||||
<select v-model="geminiTierAIStudio" class="input">
|
||||
<option value="aistudio_free">{{ t('admin.accounts.gemini.tier.aiStudio.free') }}</option>
|
||||
<option value="aistudio_paid">{{ t('admin.accounts.gemini.tier.aiStudio.paid') }}</option>
|
||||
</select>
|
||||
<p class="input-hint">{{ t('admin.accounts.gemini.tier.aiStudioHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Restriction Section (不适用于 Gemini) -->
|
||||
<div v-if="form.platform !== 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
@@ -1816,6 +1861,24 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_
|
||||
const geminiAIStudioOAuthEnabled = ref(false)
|
||||
const showAdvancedOAuth = ref(false)
|
||||
|
||||
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
|
||||
const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free')
|
||||
const geminiTierGcp = ref<'gcp_standard' | 'gcp_enterprise'>('gcp_standard')
|
||||
const geminiTierAIStudio = ref<'aistudio_free' | 'aistudio_paid'>('aistudio_free')
|
||||
|
||||
const geminiSelectedTier = computed(() => {
|
||||
if (form.platform !== 'gemini') return ''
|
||||
if (accountCategory.value === 'apikey') return geminiTierAIStudio.value
|
||||
switch (geminiOAuthType.value) {
|
||||
case 'google_one':
|
||||
return geminiTierGoogleOne.value
|
||||
case 'code_assist':
|
||||
return geminiTierGcp.value
|
||||
default:
|
||||
return geminiTierAIStudio.value
|
||||
}
|
||||
})
|
||||
|
||||
const geminiQuotaDocs = {
|
||||
codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas',
|
||||
aiStudio: 'https://ai.google.dev/pricing',
|
||||
@@ -2143,6 +2206,9 @@ const resetForm = () => {
|
||||
tempUnschedEnabled.value = false
|
||||
tempUnschedRules.value = []
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
geminiTierGoogleOne.value = 'google_one_free'
|
||||
geminiTierGcp.value = 'gcp_standard'
|
||||
geminiTierAIStudio.value = 'aistudio_free'
|
||||
oauth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
@@ -2184,6 +2250,9 @@ const handleSubmit = async () => {
|
||||
base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl,
|
||||
api_key: apiKeyValue.value.trim()
|
||||
}
|
||||
if (form.platform === 'gemini') {
|
||||
credentials.tier_id = geminiTierAIStudio.value
|
||||
}
|
||||
|
||||
// Add model mapping if configured
|
||||
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
||||
@@ -2237,7 +2306,12 @@ const handleGenerateUrl = async () => {
|
||||
if (form.platform === 'openai') {
|
||||
await openaiOAuth.generateAuthUrl(form.proxy_id)
|
||||
} else if (form.platform === 'gemini') {
|
||||
await geminiOAuth.generateAuthUrl(form.proxy_id, oauthFlowRef.value?.projectId, geminiOAuthType.value)
|
||||
await geminiOAuth.generateAuthUrl(
|
||||
form.proxy_id,
|
||||
oauthFlowRef.value?.projectId,
|
||||
geminiOAuthType.value,
|
||||
geminiSelectedTier.value
|
||||
)
|
||||
} else if (form.platform === 'antigravity') {
|
||||
await antigravityOAuth.generateAuthUrl(form.proxy_id)
|
||||
} else {
|
||||
@@ -2318,7 +2392,8 @@ const handleGeminiExchange = async (authCode: string) => {
|
||||
sessionId: geminiOAuth.sessionId.value,
|
||||
state: stateToUse,
|
||||
proxyId: form.proxy_id,
|
||||
oauthType: geminiOAuthType.value
|
||||
oauthType: geminiOAuthType.value,
|
||||
tierId: geminiSelectedTier.value
|
||||
})
|
||||
if (!tokenInfo) return
|
||||
|
||||
|
||||
@@ -88,7 +88,35 @@
|
||||
<!-- Gemini OAuth Type Selection -->
|
||||
<fieldset v-if="isGemini" class="border-0 p-0">
|
||||
<legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend>
|
||||
<div class="mt-2 grid grid-cols-2 gap-3">
|
||||
<div class="mt-2 grid grid-cols-3 gap-3">
|
||||
<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">个人账号</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="handleSelectGeminiOAuthType('code_assist')"
|
||||
@@ -305,7 +333,7 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
|
||||
|
||||
// State
|
||||
const addMethod = ref<AddMethod>('oauth')
|
||||
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist')
|
||||
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist')
|
||||
const geminiAIStudioOAuthEnabled = ref(false)
|
||||
|
||||
// Computed - check platform
|
||||
@@ -367,7 +395,12 @@ watch(
|
||||
}
|
||||
if (isGemini.value) {
|
||||
const creds = (props.account.credentials || {}) as Record<string, unknown>
|
||||
geminiOAuthType.value = creds.oauth_type === 'ai_studio' ? 'ai_studio' : 'code_assist'
|
||||
geminiOAuthType.value =
|
||||
creds.oauth_type === 'google_one'
|
||||
? 'google_one'
|
||||
: creds.oauth_type === 'ai_studio'
|
||||
? 'ai_studio'
|
||||
: 'code_assist'
|
||||
}
|
||||
if (isGemini.value) {
|
||||
geminiOAuth.getCapabilities().then((caps) => {
|
||||
@@ -395,7 +428,7 @@ const resetState = () => {
|
||||
oauthFlowRef.value?.reset()
|
||||
}
|
||||
|
||||
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'ai_studio') => {
|
||||
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'google_one' | 'ai_studio') => {
|
||||
if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
|
||||
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
|
||||
return
|
||||
@@ -413,8 +446,10 @@ const handleGenerateUrl = async () => {
|
||||
if (isOpenAI.value) {
|
||||
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
|
||||
} else if (isGemini.value) {
|
||||
const creds = (props.account.credentials || {}) as Record<string, unknown>
|
||||
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
|
||||
const projectId = geminiOAuthType.value === 'code_assist' ? oauthFlowRef.value?.projectId : undefined
|
||||
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value)
|
||||
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value, tierId)
|
||||
} else if (isAntigravity.value) {
|
||||
await antigravityOAuth.generateAuthUrl(props.account.proxy_id)
|
||||
} else {
|
||||
@@ -475,7 +510,8 @@ const handleExchangeCode = async () => {
|
||||
sessionId,
|
||||
state: stateToUse,
|
||||
proxyId: props.account.proxy_id,
|
||||
oauthType: geminiOAuthType.value
|
||||
oauthType: geminiOAuthType.value,
|
||||
tierId: typeof (props.account.credentials as any)?.tier_id === 'string' ? ((props.account.credentials as any).tier_id as string) : undefined
|
||||
})
|
||||
if (!tokenInfo) return
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ export function useGeminiOAuth() {
|
||||
const generateAuthUrl = async (
|
||||
proxyId: number | null | undefined,
|
||||
projectId?: string | null,
|
||||
oauthType?: string
|
||||
oauthType?: string,
|
||||
tierId?: string
|
||||
): Promise<boolean> => {
|
||||
loading.value = true
|
||||
authUrl.value = ''
|
||||
@@ -52,6 +53,8 @@ export function useGeminiOAuth() {
|
||||
const trimmedProjectID = projectId?.trim()
|
||||
if (trimmedProjectID) payload.project_id = trimmedProjectID
|
||||
if (oauthType) payload.oauth_type = oauthType
|
||||
const trimmedTierID = tierId?.trim()
|
||||
if (trimmedTierID) payload.tier_id = trimmedTierID
|
||||
|
||||
const response = await adminAPI.gemini.generateAuthUrl(payload as any)
|
||||
authUrl.value = response.auth_url
|
||||
@@ -73,6 +76,7 @@ export function useGeminiOAuth() {
|
||||
state: string
|
||||
proxyId?: number | null
|
||||
oauthType?: string
|
||||
tierId?: string
|
||||
}): Promise<GeminiTokenInfo | null> => {
|
||||
const code = params.code?.trim()
|
||||
if (!code || !params.sessionId || !params.state) {
|
||||
@@ -91,6 +95,8 @@ export function useGeminiOAuth() {
|
||||
}
|
||||
if (params.proxyId) payload.proxy_id = params.proxyId
|
||||
if (params.oauthType) payload.oauth_type = params.oauthType
|
||||
const trimmedTierID = params.tierId?.trim()
|
||||
if (trimmedTierID) payload.tier_id = trimmedTierID
|
||||
|
||||
const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any)
|
||||
return tokenInfo as GeminiTokenInfo
|
||||
|
||||
@@ -1257,6 +1257,25 @@ export default {
|
||||
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
|
||||
baseUrlHint: 'Leave default for official Gemini API',
|
||||
apiKeyHint: 'Your Gemini API Key (starts with AIza)',
|
||||
tier: {
|
||||
label: 'Tier (Quota Level)',
|
||||
hint: 'Tip: The system will try to auto-detect the tier first; if auto-detection is unavailable or fails, your selected tier is used as a fallback (simulated quota).',
|
||||
aiStudioHint:
|
||||
'AI Studio quotas are per-model (Pro/Flash are limited independently). If billing is enabled, choose Pay-as-you-go.',
|
||||
googleOne: {
|
||||
free: 'Google One Free (1000 RPD / 60 RPM, shared pool)',
|
||||
pro: 'Google AI Pro (1500 RPD / 120 RPM, shared pool)',
|
||||
ultra: 'Google AI Ultra (2000 RPD / 120 RPM, shared pool)'
|
||||
},
|
||||
gcp: {
|
||||
standard: 'GCP Standard (1500 RPD / 120 RPM, shared pool)',
|
||||
enterprise: 'GCP Enterprise (2000 RPD / 120 RPM, shared pool)'
|
||||
},
|
||||
aiStudio: {
|
||||
free: 'AI Studio Free Tier (Pro: 50 RPD / 2 RPM; Flash: 1500 RPD / 15 RPM)',
|
||||
paid: 'AI Studio Pay-as-you-go (Pro: ∞ RPD / 1000 RPM; Flash: ∞ RPD / 2000 RPM)'
|
||||
}
|
||||
},
|
||||
accountType: {
|
||||
oauthTitle: 'OAuth (Gemini)',
|
||||
oauthDesc: 'Authorize with your Google account and choose an OAuth type.',
|
||||
@@ -1317,6 +1336,17 @@ export default {
|
||||
},
|
||||
simulatedNote: 'Simulated quota, for reference only',
|
||||
rows: {
|
||||
googleOne: {
|
||||
channel: 'Google One OAuth (Individuals / Code Assist for Individuals)',
|
||||
limitsFree: 'Shared pool: 1000 RPD / 60 RPM',
|
||||
limitsPro: 'Shared pool: 1500 RPD / 120 RPM',
|
||||
limitsUltra: 'Shared pool: 2000 RPD / 120 RPM'
|
||||
},
|
||||
gcp: {
|
||||
channel: 'GCP Code Assist OAuth (Enterprise)',
|
||||
limitsStandard: 'Shared pool: 1500 RPD / 120 RPM',
|
||||
limitsEnterprise: 'Shared pool: 2000 RPD / 120 RPM'
|
||||
},
|
||||
cli: {
|
||||
channel: 'Gemini CLI (Official Google Login / Code Assist)',
|
||||
free: 'Free Google Account',
|
||||
@@ -1334,7 +1364,7 @@ export default {
|
||||
free: 'No billing (free tier)',
|
||||
paid: 'Billing enabled (pay-as-you-go)',
|
||||
limitsFree: 'RPD 50; RPM 2 (Pro) / 15 (Flash)',
|
||||
limitsPaid: 'RPD unlimited; RPM 1000+ (per model quota)'
|
||||
limitsPaid: 'RPD unlimited; RPM 1000 (Pro) / 2000 (Flash) (per model)'
|
||||
},
|
||||
customOAuth: {
|
||||
channel: 'Custom OAuth Client (GCP)',
|
||||
|
||||
@@ -1395,6 +1395,24 @@ export default {
|
||||
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
|
||||
baseUrlHint: '留空使用官方 Gemini API',
|
||||
apiKeyHint: '您的 Gemini API Key(以 AIza 开头)',
|
||||
tier: {
|
||||
label: 'Tier(配额等级)',
|
||||
hint: '提示:系统会优先尝试自动识别 Tier;若自动识别不可用或失败,则使用你选择的 Tier 作为回退(本地模拟配额)。',
|
||||
aiStudioHint: 'AI Studio 的配额是按模型分别限流(Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。',
|
||||
googleOne: {
|
||||
free: 'Google One Free(1000 RPD / 60 RPM,共享池)',
|
||||
pro: 'Google AI Pro(1500 RPD / 120 RPM,共享池)',
|
||||
ultra: 'Google AI Ultra(2000 RPD / 120 RPM,共享池)'
|
||||
},
|
||||
gcp: {
|
||||
standard: 'GCP Standard(1500 RPD / 120 RPM,共享池)',
|
||||
enterprise: 'GCP Enterprise(2000 RPD / 120 RPM,共享池)'
|
||||
},
|
||||
aiStudio: {
|
||||
free: 'AI Studio Free Tier(Pro: 50 RPD / 2 RPM;Flash: 1500 RPD / 15 RPM)',
|
||||
paid: 'AI Studio Pay-as-you-go(Pro: ∞ RPD / 1000 RPM;Flash: ∞ RPD / 2000 RPM)'
|
||||
}
|
||||
},
|
||||
accountType: {
|
||||
oauthTitle: 'OAuth 授权(Gemini)',
|
||||
oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。',
|
||||
@@ -1454,6 +1472,17 @@ export default {
|
||||
},
|
||||
simulatedNote: '本地模拟配额,仅供参考',
|
||||
rows: {
|
||||
googleOne: {
|
||||
channel: 'Google One OAuth(个人版 / Code Assist for Individuals)',
|
||||
limitsFree: '共享池:1000 RPD / 60 RPM(不分模型)',
|
||||
limitsPro: '共享池:1500 RPD / 120 RPM(不分模型)',
|
||||
limitsUltra: '共享池:2000 RPD / 120 RPM(不分模型)'
|
||||
},
|
||||
gcp: {
|
||||
channel: 'GCP Code Assist OAuth(企业版)',
|
||||
limitsStandard: '共享池:1500 RPD / 120 RPM(不分模型)',
|
||||
limitsEnterprise: '共享池:2000 RPD / 120 RPM(不分模型)'
|
||||
},
|
||||
cli: {
|
||||
channel: 'Gemini CLI(官方 Google 登录 / Code Assist)',
|
||||
free: '免费 Google 账号',
|
||||
@@ -1471,7 +1500,7 @@ export default {
|
||||
free: '未绑卡(免费层)',
|
||||
paid: '已绑卡(按量付费)',
|
||||
limitsFree: 'RPD 50;RPM 2(Pro)/ 15(Flash)',
|
||||
limitsPaid: 'RPD 不限;RPM 1000+(按模型配额)'
|
||||
limitsPaid: 'RPD 不限;RPM 1000(Pro)/ 2000(Flash)(按模型配额)'
|
||||
},
|
||||
customOAuth: {
|
||||
channel: 'Custom OAuth Client(GCP)',
|
||||
|
||||
@@ -322,8 +322,19 @@ export interface GeminiCredentials {
|
||||
// OAuth authentication
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
oauth_type?: 'code_assist' | 'ai_studio' | string
|
||||
tier_id?: 'LEGACY' | 'PRO' | 'ULTRA' | string
|
||||
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio' | string
|
||||
tier_id?:
|
||||
| 'google_one_free'
|
||||
| 'google_ai_pro'
|
||||
| 'google_ai_ultra'
|
||||
| 'gcp_standard'
|
||||
| 'gcp_enterprise'
|
||||
| 'aistudio_free'
|
||||
| 'aistudio_paid'
|
||||
| 'LEGACY'
|
||||
| 'PRO'
|
||||
| 'ULTRA'
|
||||
| string
|
||||
project_id?: string
|
||||
token_type?: string
|
||||
scope?: string
|
||||
@@ -397,6 +408,8 @@ export interface UsageProgress {
|
||||
resets_at: string | null
|
||||
remaining_seconds: number
|
||||
window_stats?: WindowStats | null // 窗口期统计(从窗口开始到当前的使用量)
|
||||
used_requests?: number
|
||||
limit_requests?: number
|
||||
}
|
||||
|
||||
// Antigravity 单个模型的配额信息
|
||||
@@ -410,8 +423,12 @@ export interface AccountUsageInfo {
|
||||
five_hour: UsageProgress | null
|
||||
seven_day: UsageProgress | null
|
||||
seven_day_sonnet: UsageProgress | null
|
||||
gemini_shared_daily?: UsageProgress | null
|
||||
gemini_pro_daily?: UsageProgress | null
|
||||
gemini_flash_daily?: UsageProgress | null
|
||||
gemini_shared_minute?: UsageProgress | null
|
||||
gemini_pro_minute?: UsageProgress | null
|
||||
gemini_flash_minute?: UsageProgress | null
|
||||
antigravity_quota?: Record<string, AntigravityModelQuota> | null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user