feat(gemini): 完善 Gemini OAuth 配额系统和用量显示

主要改动:
- 后端:重构 Gemini 配额服务,支持多层级配额策略(GCP Standard/Free, Google One, AI Studio, Code Assist)
- 后端:优化 OAuth 服务,增强 tier_id 识别和存储逻辑
- 后端:改进用量统计服务,支持不同平台的配额查询
- 后端:优化限流服务,增加临时解除调度状态管理
- 前端:统一四种授权方式的用量显示格式和徽标样式
- 前端:增强账户配额信息展示,支持多种配额类型
- 前端:改进创建和重新授权模态框的用户体验
- 国际化:完善中英文配额相关文案
- 移除 CHANGELOG.md 文件

测试:所有单元测试通过
This commit is contained in:
IanShaw027
2026-01-04 15:36:00 +08:00
parent cc4cc806ea
commit a185ad1144
21 changed files with 1205 additions and 368 deletions

View File

@@ -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.

View File

@@ -30,6 +30,8 @@ type GeminiGenerateAuthURLRequest struct {
// OAuth 类型: "code_assist" (需要 project_id) 或 "ai_studio" (不需要 project_id) // OAuth 类型: "code_assist" (需要 project_id) 或 "ai_studio" (不需要 project_id)
// 默认为 "code_assist" 以保持向后兼容 // 默认为 "code_assist" 以保持向后兼容
OAuthType string `json:"oauth_type"` 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. // 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 // 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. // oauth_type and whether the built-in Gemini CLI OAuth client is used.
redirectURI := deriveGeminiRedirectURI(c) 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 { if err != nil {
msg := err.Error() msg := err.Error()
// Treat missing/invalid OAuth client configuration as a user/config error. // Treat missing/invalid OAuth client configuration as a user/config error.
@@ -76,6 +78,9 @@ type GeminiExchangeCodeRequest struct {
ProxyID *int64 `json:"proxy_id"` ProxyID *int64 `json:"proxy_id"`
// OAuth 类型: "code_assist" 或 "ai_studio",需要与 GenerateAuthURL 时的类型一致 // OAuth 类型: "code_assist" 或 "ai_studio",需要与 GenerateAuthURL 时的类型一致
OAuthType string `json:"oauth_type"` 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. // ExchangeCode exchanges authorization code for tokens.
@@ -103,6 +108,7 @@ func (h *GeminiOAuthHandler) ExchangeCode(c *gin.Context) {
Code: req.Code, Code: req.Code,
ProxyID: req.ProxyID, ProxyID: req.ProxyID,
OAuthType: oauthType, OAuthType: oauthType,
TierID: req.TierID,
}) })
if err != nil { if err != nil {
response.BadRequest(c, "Failed to exchange code: "+err.Error()) response.BadRequest(c, "Failed to exchange code: "+err.Error())

View File

@@ -19,13 +19,17 @@ type OAuthConfig struct {
} }
type OAuthSession struct { type OAuthSession struct {
State string `json:"state"` State string `json:"state"`
CodeVerifier string `json:"code_verifier"` CodeVerifier string `json:"code_verifier"`
ProxyURL string `json:"proxy_url,omitempty"` ProxyURL string `json:"proxy_url,omitempty"`
RedirectURI string `json:"redirect_uri"` RedirectURI string `json:"redirect_uri"`
ProjectID string `json:"project_id,omitempty"` ProjectID string `json:"project_id,omitempty"`
OAuthType string `json:"oauth_type"` // "code_assist" 或 "ai_studio" // TierID is a user-selected fallback tier.
CreatedAt time.Time `json:"created_at"` // 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 { type SessionStore struct {

View File

@@ -30,14 +30,14 @@ func (c *geminiOAuthClient) ExchangeCode(ctx context.Context, oauthType, code, c
// Use different OAuth clients based on oauthType: // Use different OAuth clients based on oauthType:
// - code_assist: always use built-in Gemini CLI OAuth client (public) // - 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 // - ai_studio: requires a user-provided OAuth client
oauthCfgInput := geminicli.OAuthConfig{ oauthCfgInput := geminicli.OAuthConfig{
ClientID: c.cfg.Gemini.OAuth.ClientID, ClientID: c.cfg.Gemini.OAuth.ClientID,
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret, ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
Scopes: c.cfg.Gemini.OAuth.Scopes, Scopes: c.cfg.Gemini.OAuth.Scopes,
} }
if oauthType == "code_assist" || oauthType == "google_one" { if oauthType == "code_assist" {
oauthCfgInput.ClientID = "" oauthCfgInput.ClientID = ""
oauthCfgInput.ClientSecret = "" oauthCfgInput.ClientSecret = ""
} }
@@ -78,7 +78,7 @@ func (c *geminiOAuthClient) RefreshToken(ctx context.Context, oauthType, refresh
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret, ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
Scopes: c.cfg.Gemini.OAuth.Scopes, Scopes: c.cfg.Gemini.OAuth.Scopes,
} }
if oauthType == "code_assist" || oauthType == "google_one" { if oauthType == "code_assist" {
oauthCfgInput.ClientID = "" oauthCfgInput.ClientID = ""
oauthCfgInput.ClientSecret = "" oauthCfgInput.ClientSecret = ""
} }

View File

@@ -105,10 +105,7 @@ func (a *Account) GeminiOAuthType() string {
func (a *Account) GeminiTierID() string { func (a *Account) GeminiTierID() string {
tierID := strings.TrimSpace(a.GetCredential("tier_id")) tierID := strings.TrimSpace(a.GetCredential("tier_id"))
if tierID == "" { return tierID
return ""
}
return strings.ToUpper(tierID)
} }
func (a *Account) IsGeminiCodeAssist() bool { func (a *Account) IsGeminiCodeAssist() bool {

View File

@@ -107,6 +107,8 @@ type UsageProgress struct {
ResetsAt *time.Time `json:"resets_at"` // 重置时间 ResetsAt *time.Time `json:"resets_at"` // 重置时间
RemainingSeconds int `json:"remaining_seconds"` // 距重置剩余秒数 RemainingSeconds int `json:"remaining_seconds"` // 距重置剩余秒数
WindowStats *WindowStats `json:"window_stats,omitempty"` // 窗口期统计(从窗口开始到当前的使用量) WindowStats *WindowStats `json:"window_stats,omitempty"` // 窗口期统计(从窗口开始到当前的使用量)
UsedRequests int64 `json:"used_requests,omitempty"`
LimitRequests int64 `json:"limit_requests,omitempty"`
} }
// AntigravityModelQuota Antigravity 单个模型的配额信息 // AntigravityModelQuota Antigravity 单个模型的配额信息
@@ -117,12 +119,16 @@ type AntigravityModelQuota struct {
// UsageInfo 账号使用量信息 // UsageInfo 账号使用量信息
type UsageInfo struct { type UsageInfo struct {
UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间 UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口 FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口
SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口 SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口
SevenDaySonnet *UsageProgress `json:"seven_day_sonnet,omitempty"` // 7天Sonnet窗口 SevenDaySonnet *UsageProgress `json:"seven_day_sonnet,omitempty"` // 7天Sonnet窗口
GeminiProDaily *UsageProgress `json:"gemini_pro_daily,omitempty"` // Gemini Pro 日配额 GeminiSharedDaily *UsageProgress `json:"gemini_shared_daily,omitempty"` // Gemini shared pool RPD (Google One / Code Assist)
GeminiFlashDaily *UsageProgress `json:"gemini_flash_daily,omitempty"` // Gemini Flash 日配额 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 多模型配额 // Antigravity 多模型配额
AntigravityQuota map[string]*AntigravityModelQuota `json:"antigravity_quota,omitempty"` AntigravityQuota map[string]*AntigravityModelQuota `json:"antigravity_quota,omitempty"`
@@ -258,17 +264,44 @@ func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Accou
return usage, nil return usage, nil
} }
start := geminiDailyWindowStart(now) dayStart := geminiDailyWindowStart(now)
stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID) stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, dayStart, now, 0, 0, account.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("get gemini usage stats failed: %w", err) return nil, fmt.Errorf("get gemini usage stats failed: %w", err)
} }
totals := geminiAggregateUsage(stats) dayTotals := geminiAggregateUsage(stats)
resetAt := geminiDailyResetTime(now) dailyResetAt := geminiDailyResetTime(now)
usage.GeminiProDaily = buildGeminiUsageProgress(totals.ProRequests, quota.ProRPD, resetAt, totals.ProTokens, totals.ProCost, now) // Daily window (RPD)
usage.GeminiFlashDaily = buildGeminiUsageProgress(totals.FlashRequests, quota.FlashRPD, resetAt, totals.FlashTokens, totals.FlashCost, now) 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 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 { 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 { if limit <= 0 {
return nil return nil
} }
@@ -521,6 +555,8 @@ func buildGeminiUsageProgress(used, limit int64, resetAt time.Time, tokens int64
Utilization: utilization, Utilization: utilization,
ResetsAt: &resetCopy, ResetsAt: &resetCopy,
RemainingSeconds: remainingSeconds, RemainingSeconds: remainingSeconds,
UsedRequests: used,
LimitRequests: limit,
WindowStats: &WindowStats{ WindowStats: &WindowStats{
Requests: used, Requests: used,
Tokens: tokens, Tokens: tokens,

View File

@@ -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 break
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()

View File

@@ -1628,6 +1628,15 @@ type UpstreamHTTPResult struct {
} }
func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Context, resp *http.Response, isOAuth bool) (*ClaudeUsage, error) { 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) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, err 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) { 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.Status(resp.StatusCode)
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive") c.Header("Connection", "keep-alive")

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
@@ -18,12 +19,23 @@ import (
) )
const ( const (
TierAIPremium = "AI_PREMIUM" // Canonical tier IDs used by sub2api (2026-aligned).
TierGoogleOneStandard = "GOOGLE_ONE_STANDARD" GeminiTierGoogleOneFree = "google_one_free"
TierGoogleOneBasic = "GOOGLE_ONE_BASIC" GeminiTierGoogleAIPro = "google_ai_pro"
TierFree = "FREE" GeminiTierGoogleAIUltra = "google_ai_ultra"
TierGoogleOneUnknown = "GOOGLE_ONE_UNKNOWN" GeminiTierGCPStandard = "gcp_standard"
TierGoogleOneUnlimited = "GOOGLE_ONE_UNLIMITED" 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 ( const (
@@ -84,7 +96,7 @@ type GeminiAuthURLResult struct {
State string `json:"state"` 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() state, err := geminicli.GenerateState()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate state: %w", err) 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: // OAuth client selection:
// - code_assist: always use built-in Gemini CLI OAuth client (public), regardless of configured client_id/secret. // - code_assist: always use built-in Gemini CLI OAuth client (public), regardless of configured client_id/secret.
// - google_one: same as code_assist, uses built-in client for personal Google accounts. // - google_one: uses configured OAuth client when provided; otherwise falls back to built-in client.
// - ai_studio: requires a user-provided OAuth client. // - ai_studio: requires a user-provided OAuth client.
oauthCfg := geminicli.OAuthConfig{ oauthCfg := geminicli.OAuthConfig{
ClientID: s.cfg.Gemini.OAuth.ClientID, ClientID: s.cfg.Gemini.OAuth.ClientID,
ClientSecret: s.cfg.Gemini.OAuth.ClientSecret, ClientSecret: s.cfg.Gemini.OAuth.ClientSecret,
Scopes: s.cfg.Gemini.OAuth.Scopes, Scopes: s.cfg.Gemini.OAuth.Scopes,
} }
if oauthType == "code_assist" || oauthType == "google_one" { if oauthType == "code_assist" {
oauthCfg.ClientID = "" oauthCfg.ClientID = ""
oauthCfg.ClientSecret = "" oauthCfg.ClientSecret = ""
} }
@@ -127,6 +139,7 @@ func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64
ProxyURL: proxyURL, ProxyURL: proxyURL,
RedirectURI: redirectURI, RedirectURI: redirectURI,
ProjectID: strings.TrimSpace(projectID), ProjectID: strings.TrimSpace(projectID),
TierID: canonicalGeminiTierIDForOAuthType(oauthType, tierID),
OAuthType: oauthType, OAuthType: oauthType,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
@@ -146,9 +159,9 @@ func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64
} }
// Redirect URI strategy: // Redirect URI strategy:
// - code_assist: use Gemini CLI redirect URI (codeassist.google.com/authcode) // - built-in Gemini CLI OAuth client: use upstream redirect URI (codeassist.google.com/authcode)
// - ai_studio: use localhost callback for manual copy/paste flow // - custom OAuth client: use localhost callback for manual copy/paste flow
if oauthType == "code_assist" { if isBuiltinClient {
redirectURI = geminicli.GeminiCLIRedirectURI redirectURI = geminicli.GeminiCLIRedirectURI
} else { } else {
redirectURI = geminicli.AIStudioOAuthRedirectURI redirectURI = geminicli.AIStudioOAuthRedirectURI
@@ -174,6 +187,9 @@ type GeminiExchangeCodeInput struct {
Code string Code string
ProxyID *int64 ProxyID *int64
OAuthType string // "code_assist" 或 "ai_studio" 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 { type GeminiTokenInfo struct {
@@ -185,7 +201,7 @@ type GeminiTokenInfo struct {
Scope string `json:"scope,omitempty"` Scope string `json:"scope,omitempty"`
ProjectID string `json:"project_id,omitempty"` ProjectID string `json:"project_id,omitempty"`
OAuthType string `json:"oauth_type,omitempty"` // "code_assist" 或 "ai_studio" OAuthType string `json:"oauth_type,omitempty"` // "code_assist" 或 "ai_studio"
TierID string `json:"tier_id,omitempty"` // Gemini Code Assist tier: LEGACY/PRO/ULTRA TierID string `json:"tier_id,omitempty"` // Canonical tier id (e.g. google_one_free, gcp_standard, aistudio_free)
Extra map[string]any `json:"extra,omitempty"` // Drive metadata Extra map[string]any `json:"extra,omitempty"` // Drive metadata
} }
@@ -204,6 +220,90 @@ func validateTierID(tierID string) error {
return nil 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 // extractTierIDFromAllowedTiers extracts tierID from LoadCodeAssist response
// Prioritizes IsDefault tier, falls back to first non-empty tier // Prioritizes IsDefault tier, falls back to first non-empty tier
func extractTierIDFromAllowedTiers(allowedTiers []geminicli.AllowedTier) string { func extractTierIDFromAllowedTiers(allowedTiers []geminicli.AllowedTier) string {
@@ -233,55 +333,35 @@ func inferGoogleOneTier(storageBytes int64) string {
if storageBytes <= 0 { if storageBytes <= 0 {
log.Printf("[GeminiOAuth] inferGoogleOneTier - storageBytes <= 0, returning UNKNOWN") log.Printf("[GeminiOAuth] inferGoogleOneTier - storageBytes <= 0, returning UNKNOWN")
return TierGoogleOneUnknown return GeminiTierGoogleOneUnknown
} }
if storageBytes > StorageTierUnlimited { if storageBytes > StorageTierUnlimited {
log.Printf("[GeminiOAuth] inferGoogleOneTier - > %d bytes (100TB), returning UNLIMITED", StorageTierUnlimited) log.Printf("[GeminiOAuth] inferGoogleOneTier - > %d bytes (100TB), returning UNLIMITED", StorageTierUnlimited)
return TierGoogleOneUnlimited return GeminiTierGoogleAIUltra
} }
if storageBytes >= StorageTierAIPremium { if storageBytes >= StorageTierAIPremium {
log.Printf("[GeminiOAuth] inferGoogleOneTier - >= %d bytes (2TB), returning AI_PREMIUM", StorageTierAIPremium) log.Printf("[GeminiOAuth] inferGoogleOneTier - >= %d bytes (2TB), returning google_ai_pro", StorageTierAIPremium)
return TierAIPremium return GeminiTierGoogleAIPro
}
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
} }
if storageBytes >= StorageTierFree { if storageBytes >= StorageTierFree {
log.Printf("[GeminiOAuth] inferGoogleOneTier - >= %d bytes (15GB), returning FREE", 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) 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) { 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) // Use Drive API to infer tier from storage quota (requires drive.readonly scope)
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)
log.Printf("[GeminiOAuth] Calling Drive API for storage quota...") log.Printf("[GeminiOAuth] Calling Drive API for storage quota...")
driveClient := geminicli.NewDriveClient() driveClient := geminicli.NewDriveClient()
@@ -290,11 +370,11 @@ func (s *GeminiOAuthService) FetchGoogleOneTier(ctx context.Context, accessToken
// Check if it's a 403 (scope not granted) // Check if it's a 403 (scope not granted)
if strings.Contains(err.Error(), "status 403") { if strings.Contains(err.Error(), "status 403") {
log.Printf("[GeminiOAuth] Drive API scope not available (403): %v", err) log.Printf("[GeminiOAuth] Drive API scope not available (403): %v", err)
return TierGoogleOneUnknown, nil, err return GeminiTierGoogleOneUnknown, nil, err
} }
// Other errors // Other errors
log.Printf("[GeminiOAuth] Failed to fetch Drive storage: %v", err) 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)", 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) { 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) session, ok := s.sessionStore.Get(input.SessionID)
if !ok { if !ok {
log.Printf("[GeminiOAuth] ERROR: Session not found or expired")
return nil, fmt.Errorf("session not found or expired") return nil, fmt.Errorf("session not found or expired")
} }
if strings.TrimSpace(input.State) == "" || input.State != session.State { if strings.TrimSpace(input.State) == "" || input.State != session.State {
log.Printf("[GeminiOAuth] ERROR: Invalid state")
return nil, fmt.Errorf("invalid state") return nil, fmt.Errorf("invalid state")
} }
@@ -377,6 +462,7 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
proxyURL = proxy.URL() proxyURL = proxy.URL()
} }
} }
log.Printf("[GeminiOAuth] ProxyURL: %s", proxyURL)
redirectURI := session.RedirectURI redirectURI := session.RedirectURI
@@ -385,6 +471,8 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
if oauthType == "" { if oauthType == "" {
oauthType = "code_assist" 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 the session was created for AI Studio OAuth, ensure a custom OAuth client is configured.
if oauthType == "ai_studio" { 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) tokenResp, err := s.oauthClient.ExchangeCode(ctx, oauthType, input.Code, session.CodeVerifier, redirectURI, proxyURL)
if err != nil { if err != nil {
log.Printf("[GeminiOAuth] ERROR: Failed to exchange code: %v", err)
return nil, fmt.Errorf("failed to exchange code: %w", 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) sessionProjectID := strings.TrimSpace(session.ProjectID)
s.sessionStore.Delete(input.SessionID) s.sessionStore.Delete(input.SessionID)
@@ -427,36 +520,63 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
projectID := sessionProjectID projectID := sessionProjectID
var tierID string 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 // 对于 code_assist 模式project_id 是必需的,需要调用 Code Assist API
// 对于 google_one 模式,使用个人 Google 账号,不需要 project_id配额由 Google 网关自动识别 // 对于 google_one 模式,使用个人 Google 账号,不需要 project_id配额由 Google 网关自动识别
// 对于 ai_studio 模式project_id 是可选的(不影响使用 AI Studio API // 对于 ai_studio 模式project_id 是可选的(不影响使用 AI Studio API
switch oauthType { switch oauthType {
case "code_assist": case "code_assist":
log.Printf("[GeminiOAuth] Processing code_assist OAuth type")
if projectID == "" { if projectID == "" {
log.Printf("[GeminiOAuth] No project_id provided, attempting to fetch from LoadCodeAssist API...")
var err error var err error
projectID, tierID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL) projectID, tierID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
if err != nil { if err != nil {
// 记录警告但不阻断流程,允许后续补充 project_id // 记录警告但不阻断流程,允许后续补充 project_id
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch project_id during token exchange: %v\n", err) 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 { } else {
log.Printf("[GeminiOAuth] User provided project_id: %s, fetching tier_id...", projectID)
// 用户手动填了 project_id仍需调用 LoadCodeAssist 获取 tierID // 用户手动填了 project_id仍需调用 LoadCodeAssist 获取 tierID
_, fetchedTierID, err := s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL) _, fetchedTierID, err := s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
if err != nil { if err != nil {
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch tierID: %v\n", err) fmt.Printf("[GeminiOAuth] Warning: Failed to fetch tierID: %v\n", err)
log.Printf("[GeminiOAuth] WARNING: Failed to fetch tier_id: %v", err)
} else { } else {
tierID = fetchedTierID tierID = fetchedTierID
log.Printf("[GeminiOAuth] Successfully fetched tier_id: %s", tierID)
} }
} }
if strings.TrimSpace(projectID) == "" { 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") 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 == "" { 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": 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 // Attempt to fetch Drive storage tier
var storageInfo *geminicli.DriveStorageInfo var storageInfo *geminicli.DriveStorageInfo
var err error var err error
@@ -464,9 +584,27 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
if err != nil { if err != nil {
// Log warning but don't block - use fallback // Log warning but don't block - use fallback
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch Drive tier: %v\n", err) 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 // Store Drive info in extra field for caching
if storageInfo != nil { 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), "drive_tier_updated_at": time.Now().Format(time.RFC3339),
}, },
} }
log.Printf("[GeminiOAuth] ========== ExchangeCode END (google_one with storage info) ==========")
return tokenInfo, nil 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{ result := &GeminiTokenInfo{
AccessToken: tokenResp.AccessToken, AccessToken: tokenResp.AccessToken,
@@ -502,7 +653,8 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
TierID: tierID, TierID: tierID,
OAuthType: oauthType, 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 return result, nil
} }
@@ -599,6 +751,17 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
err = nil 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 { if err != nil {
// Provide a more actionable error for common OAuth client mismatch issues. // Provide a more actionable error for common OAuth client mismatch issues.
if strings.Contains(err.Error(), "unauthorized_client") { if strings.Contains(err.Error(), "unauthorized_client") {
@@ -624,13 +787,14 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
case "code_assist": case "code_assist":
// 先设置默认值或保留旧值,确保 tier_id 始终有值 // 先设置默认值或保留旧值,确保 tier_id 始终有值
if existingTierID != "" { if existingTierID != "" {
tokenInfo.TierID = existingTierID tokenInfo.TierID = canonicalGeminiTierIDForOAuthType(oauthType, existingTierID)
} else { }
tokenInfo.TierID = "LEGACY" // 默认值 if tokenInfo.TierID == "" {
tokenInfo.TierID = GeminiTierGCPStandard
} }
// 尝试自动探测 project_id 和 tier_id // 尝试自动探测 project_id 和 tier_id
needDetect := strings.TrimSpace(tokenInfo.ProjectID) == "" || existingTierID == "" needDetect := strings.TrimSpace(tokenInfo.ProjectID) == "" || tokenInfo.TierID == ""
if needDetect { if needDetect {
projectID, tierID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL) projectID, tierID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL)
if err != nil { if err != nil {
@@ -639,9 +803,10 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
if strings.TrimSpace(tokenInfo.ProjectID) == "" && projectID != "" { if strings.TrimSpace(tokenInfo.ProjectID) == "" && projectID != "" {
tokenInfo.ProjectID = projectID tokenInfo.ProjectID = projectID
} }
// 只有当原来没有 tier_id 且探测成功时才更新 if tierID != "" {
if existingTierID == "" && tierID != "" { if canonical := canonicalGeminiTierIDForOAuthType(oauthType, tierID); canonical != "" {
tokenInfo.TierID = tierID 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") return nil, fmt.Errorf("failed to auto-detect project_id: empty result")
} }
case "google_one": case "google_one":
canonicalExistingTier := canonicalGeminiTierIDForOAuthType(oauthType, existingTierID)
// Check if tier cache is stale (> 24 hours) // Check if tier cache is stale (> 24 hours)
needsRefresh := true needsRefresh := true
if account.Extra != nil { if account.Extra != nil {
@@ -658,30 +824,37 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
if time.Since(updatedAt) <= 24*time.Hour { if time.Since(updatedAt) <= 24*time.Hour {
needsRefresh = false needsRefresh = false
// Use cached tier // Use cached tier
if existingTierID != "" { tokenInfo.TierID = canonicalExistingTier
tokenInfo.TierID = existingTierID
}
} }
} }
} }
} }
if tokenInfo.TierID == "" {
tokenInfo.TierID = canonicalExistingTier
}
if needsRefresh { if needsRefresh {
tierID, storageInfo, err := s.FetchGoogleOneTier(ctx, tokenInfo.AccessToken, proxyURL) tierID, storageInfo, err := s.FetchGoogleOneTier(ctx, tokenInfo.AccessToken, proxyURL)
if err == nil && storageInfo != nil { if err == nil {
tokenInfo.TierID = tierID if canonical := canonicalGeminiTierIDForOAuthType(oauthType, tierID); canonical != "" && canonical != GeminiTierGoogleOneUnknown {
tokenInfo.Extra = map[string]any{ tokenInfo.TierID = canonical
"drive_storage_limit": storageInfo.Limit,
"drive_storage_usage": storageInfo.Usage,
"drive_tier_updated_at": time.Now().Format(time.RFC3339),
} }
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 { } else {
// Fallback to cached or unknown tokenInfo.TierID = GeminiTierGoogleOneFree
if existingTierID != "" {
tokenInfo.TierID = existingTierID
} else {
tokenInfo.TierID = TierGoogleOneUnknown
}
} }
} }
} }

View File

@@ -1,50 +1,129 @@
package service package service
import "testing" import (
"context"
"net/url"
"strings"
"testing"
func TestInferGoogleOneTier(t *testing.T) { "github.com/Wei-Shaw/sub2api/internal/config"
tests := []struct { "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
name string )
storageBytes int64
expectedTier string
}{
{"Negative storage", -1, TierGoogleOneUnknown},
{"Zero storage", 0, TierGoogleOneUnknown},
// Free tier boundary (15GB) func TestGeminiOAuthService_GenerateAuthURL_RedirectURIStrategy(t *testing.T) {
{"Below free tier", 10 * GB, TierGoogleOneUnknown}, t.Parallel()
{"Just below free tier", StorageTierFree - 1, TierGoogleOneUnknown},
{"Free tier (15GB)", StorageTierFree, TierFree},
// Basic tier boundary (100GB) type testCase struct {
{"Between free and basic", 50 * GB, TierFree}, name string
{"Just below basic tier", StorageTierBasic - 1, TierFree}, cfg *config.Config
{"Basic tier (100GB)", StorageTierBasic, TierGoogleOneBasic}, oauthType string
projectID string
wantClientID string
wantRedirect string
wantScope string
wantProjectID string
wantErrSubstr string
}
// Standard tier boundary (200GB) tests := []testCase{
{"Between basic and standard", 150 * GB, TierGoogleOneBasic}, {
{"Just below standard tier", StorageTierStandard - 1, TierGoogleOneBasic}, name: "google_one uses built-in client when not configured and redirects to upstream",
{"Standard tier (200GB)", StorageTierStandard, TierGoogleOneStandard}, cfg: &config.Config{
Gemini: config.GeminiConfig{
// AI Premium tier boundary (2TB) OAuth: config.GeminiOAuthConfig{},
{"Between standard and premium", 1 * TB, TierGoogleOneStandard}, },
{"Just below AI Premium tier", StorageTierAIPremium - 1, TierGoogleOneStandard}, },
{"AI Premium tier (2TB)", StorageTierAIPremium, TierAIPremium}, oauthType: "google_one",
wantClientID: geminicli.GeminiCLIOAuthClientID,
// Unlimited tier boundary (> 100TB) wantRedirect: geminicli.GeminiCLIRedirectURI,
{"Between premium and unlimited", 50 * TB, TierAIPremium}, wantScope: geminicli.DefaultCodeAssistScopes,
{"At unlimited threshold (100TB)", StorageTierUnlimited, TierAIPremium}, wantProjectID: "",
{"Unlimited tier (100TB+)", StorageTierUnlimited + 1, TierGoogleOneUnlimited}, },
{"Unlimited tier (101TB+)", 101 * TB, TierGoogleOneUnlimited}, {
{"Very large storage", 1000 * TB, TierGoogleOneUnlimited}, 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 { for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := inferGoogleOneTier(tt.storageBytes) t.Parallel()
if result != tt.expectedTier {
t.Errorf("inferGoogleOneTier(%d) = %s, want %s", svc := NewGeminiOAuthService(nil, nil, nil, tt.cfg)
tt.storageBytes, result, tt.expectedTier) 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)
} }
}) })
} }

View File

@@ -20,13 +20,24 @@ const (
geminiModelFlash geminiModelClass = "flash" geminiModelFlash geminiModelClass = "flash"
) )
type GeminiDailyQuota struct { type GeminiQuota struct {
ProRPD int64 // SharedRPD is a shared requests-per-day pool across models.
FlashRPD int64 // 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 { type GeminiTierPolicy struct {
Quota GeminiDailyQuota Quota GeminiQuota
Cooldown time.Duration Cooldown time.Duration
} }
@@ -45,10 +56,27 @@ type GeminiUsageTotals struct {
const geminiQuotaCacheTTL = time.Minute const geminiQuotaCacheTTL = time.Minute
type geminiQuotaOverrides struct { type geminiQuotaOverridesV1 struct {
Tiers map[string]config.GeminiTierQuotaConfig `json:"tiers"` 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 { type GeminiQuotaService struct {
cfg *config.Config cfg *config.Config
settingRepo SettingRepository settingRepo SettingRepository
@@ -82,11 +110,17 @@ func (s *GeminiQuotaService) Policy(ctx context.Context) *GeminiQuotaPolicy {
if s.cfg != nil { if s.cfg != nil {
policy.ApplyOverrides(s.cfg.Gemini.Quota.Tiers) policy.ApplyOverrides(s.cfg.Gemini.Quota.Tiers)
if strings.TrimSpace(s.cfg.Gemini.Quota.Policy) != "" { if strings.TrimSpace(s.cfg.Gemini.Quota.Policy) != "" {
var overrides geminiQuotaOverrides raw := []byte(s.cfg.Gemini.Quota.Policy)
if err := json.Unmarshal([]byte(s.cfg.Gemini.Quota.Policy), &overrides); err != nil { var overridesV2 geminiQuotaOverridesV2
log.Printf("gemini quota: parse config policy failed: %v", err) if err := json.Unmarshal(raw, &overridesV2); err == nil && len(overridesV2.QuotaRules) > 0 {
policy.ApplyQuotaRulesOverrides(overridesV2.QuotaRules)
} else { } 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) { if err != nil && !errors.Is(err, ErrSettingNotFound) {
log.Printf("gemini quota: load setting failed: %v", err) log.Printf("gemini quota: load setting failed: %v", err)
} else if strings.TrimSpace(value) != "" { } else if strings.TrimSpace(value) != "" {
var overrides geminiQuotaOverrides raw := []byte(value)
if err := json.Unmarshal([]byte(value), &overrides); err != nil { var overridesV2 geminiQuotaOverridesV2
log.Printf("gemini quota: parse setting failed: %v", err) if err := json.Unmarshal(raw, &overridesV2); err == nil && len(overridesV2.QuotaRules) > 0 {
policy.ApplyQuotaRulesOverrides(overridesV2.QuotaRules)
} else { } 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 return policy
} }
func (s *GeminiQuotaService) QuotaForAccount(ctx context.Context, account *Account) (GeminiDailyQuota, bool) { func (s *GeminiQuotaService) QuotaForAccount(ctx context.Context, account *Account) (GeminiQuota, bool) {
if account == nil || !account.IsGeminiCodeAssist() { if account == nil || account.Platform != PlatformGemini {
return GeminiDailyQuota{}, false 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) policy := s.Policy(ctx)
return policy.QuotaForTier(account.GeminiTierID()) return policy.QuotaForTier(tierKey)
} }
func (s *GeminiQuotaService) CooldownForTier(ctx context.Context, tierID string) time.Duration { 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) 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 { func newGeminiQuotaPolicy() *GeminiQuotaPolicy {
return &GeminiQuotaPolicy{ return &GeminiQuotaPolicy{
tiers: map[string]GeminiTierPolicy{ tiers: map[string]GeminiTierPolicy{
"LEGACY": {Quota: GeminiDailyQuota{ProRPD: 50, FlashRPD: 1500}, Cooldown: 30 * time.Minute}, // --- AI Studio / API Key (per-model) ---
"PRO": {Quota: GeminiDailyQuota{ProRPD: 1500, FlashRPD: 4000}, Cooldown: 5 * time.Minute}, // aistudio_free:
"ULTRA": {Quota: GeminiDailyQuota{ProRPD: 2000, FlashRPD: 0}, Cooldown: 5 * time.Minute}, // - 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 { if !ok {
policy = GeminiTierPolicy{Cooldown: 5 * time.Minute} 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 { 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 { 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 { if override.CooldownMinutes != nil {
minutes := clampGeminiQuotaInt(*override.CooldownMinutes) 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) policy, ok := p.policyForTier(tierID)
if !ok { if !ok {
return GeminiDailyQuota{}, false return GeminiQuota{}, false
} }
return policy.Quota, true return policy.Quota, true
} }
@@ -184,22 +308,43 @@ func (p *GeminiQuotaPolicy) policyForTier(tierID string) (GeminiTierPolicy, bool
return GeminiTierPolicy{}, false return GeminiTierPolicy{}, false
} }
normalized := normalizeGeminiTierID(tierID) normalized := normalizeGeminiTierID(tierID)
if normalized == "" {
normalized = "LEGACY"
}
if policy, ok := p.tiers[normalized]; ok { if policy, ok := p.tiers[normalized]; ok {
return policy, true return policy, true
} }
policy, ok := p.tiers["LEGACY"] return GeminiTierPolicy{}, false
return policy, ok
} }
func normalizeGeminiTierID(tierID string) string { 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 { func clampGeminiQuotaInt64WithUnlimited(value int64) int64 {
if value < 0 { if value < -1 {
return 0 return 0
} }
return value return value
@@ -212,11 +357,46 @@ func clampGeminiQuotaInt(value int) int {
return value return value
} }
func clampGeminiQuotaRPM(value int64) int64 {
if value < 0 {
return 0
}
return value
}
func geminiCooldownForTier(tierID string) time.Duration { func geminiCooldownForTier(tierID string) time.Duration {
policy := newGeminiQuotaPolicy() policy := newGeminiQuotaPolicy()
return policy.CooldownForTier(tierID) 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 { func geminiModelClassFromName(model string) geminiModelClass {
name := strings.ToLower(strings.TrimSpace(model)) name := strings.ToLower(strings.TrimSpace(model))
if strings.Contains(name, "flash") || strings.Contains(name, "lite") { if strings.Contains(name, "flash") || strings.Contains(name, "lite") {

View File

@@ -92,7 +92,7 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
// PreCheckUsage proactively checks local quota before dispatching a request. // PreCheckUsage proactively checks local quota before dispatching a request.
// Returns false when the account should be skipped. // Returns false when the account should be skipped.
func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account, requestedModel string) (bool, error) { 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 return true, nil
} }
if s.usageRepo == nil || s.geminiQuotaService == nil { if s.usageRepo == nil || s.geminiQuotaService == nil {
@@ -104,44 +104,99 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account,
return true, nil 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() now := time.Now()
start := geminiDailyWindowStart(now) modelClass := geminiModelClassFromName(requestedModel)
totals, ok := s.getGeminiUsageTotals(account.ID, start, now)
if !ok { // 1) Daily quota precheck (RPD; resets at PST midnight)
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID) {
if err != nil { var limit int64
return true, err 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 // 2) Minute quota precheck (RPM; fixed window current minute)
switch geminiModelClassFromName(requestedModel) { {
case geminiModelFlash: var limit int64
used = totals.FlashRequests if quota.SharedRPM > 0 {
default: limit = quota.SharedRPM
used = totals.ProRequests } else {
} switch modelClass {
case geminiModelFlash:
if used >= limit { limit = quota.FlashRPM
resetAt := geminiDailyResetTime(now) default:
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil { limit = quota.ProRPM
log.Printf("SetRateLimited failed for account %d: %v", account.ID, err) }
}
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 return true, nil
@@ -186,7 +241,10 @@ func (s *RateLimitService) GeminiCooldown(ctx context.Context, account *Account)
if account == nil { if account == nil {
return 5 * time.Minute 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),停止账号调度 // handleAuthError 处理认证类错误(401/403),停止账号调度

View File

@@ -20,6 +20,7 @@ export interface GeminiAuthUrlRequest {
proxy_id?: number proxy_id?: number
project_id?: string project_id?: string
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio' oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
tier_id?: string
} }
export interface GeminiExchangeCodeRequest { export interface GeminiExchangeCodeRequest {
@@ -28,6 +29,7 @@ export interface GeminiExchangeCodeRequest {
code: string code: string
proxy_id?: number proxy_id?: number
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio' oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
tier_id?: string
} }
export type GeminiTokenInfo = { export type GeminiTokenInfo = {

View File

@@ -65,32 +65,33 @@ const tierLabel = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined const creds = props.account.credentials as GeminiCredentials | undefined
if (isCodeAssist.value) { if (isCodeAssist.value) {
// GCP Code Assist: 显示 GCP tier const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
const tierMap: Record<string, string> = { if (tier === 'gcp_enterprise') return 'GCP Enterprise'
LEGACY: 'Free', if (tier === 'gcp_standard') return 'GCP Standard'
PRO: 'Pro', // Backward compatibility
ULTRA: 'Ultra', const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
'standard-tier': 'Standard', if (upper.includes('ULTRA') || upper.includes('ENTERPRISE')) return 'GCP Enterprise'
'pro-tier': 'Pro', if (upper) return `GCP ${upper}`
'ultra-tier': 'Ultra' return 'GCP'
}
return tierMap[creds?.tier_id || ''] || (creds?.tier_id ? 'GCP' : 'Unknown')
} }
if (isGoogleOne.value) { if (isGoogleOne.value) {
// Google One: tier 映射 const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
const tierMap: Record<string, string> = { if (tier === 'google_ai_ultra') return 'Google AI Ultra'
AI_PREMIUM: 'AI Premium', if (tier === 'google_ai_pro') return 'Google AI Pro'
GOOGLE_ONE_STANDARD: 'Standard', if (tier === 'google_one_free') return 'Google One Free'
GOOGLE_ONE_BASIC: 'Basic', // Backward compatibility
FREE: 'Free', const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
GOOGLE_ONE_UNKNOWN: 'Personal', if (upper === 'AI_PREMIUM') return 'Google AI Pro'
GOOGLE_ONE_UNLIMITED: 'Unlimited' if (upper === 'GOOGLE_ONE_UNLIMITED') return 'Google AI Ultra'
} if (upper) return `Google One ${upper}`
return tierMap[creds?.tier_id || ''] || 'Personal' return 'Google One'
} }
// API Key: 显示 AI Studio // 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' return 'AI Studio'
}) })
@@ -99,35 +100,31 @@ const tierBadgeClass = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined const creds = props.account.credentials as GeminiCredentials | undefined
if (isCodeAssist.value) { if (isCodeAssist.value) {
// GCP Code Assist 样式 const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
const tierColorMap: Record<string, string> = { if (tier === 'gcp_enterprise') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
LEGACY: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300', if (tier === 'gcp_standard') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
PRO: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300', // Backward compatibility
ULTRA: 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300', const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
'standard-tier': 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-300', if (upper.includes('ULTRA') || upper.includes('ENTERPRISE')) return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
'pro-tier': 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300', return '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'
)
} }
if (isGoogleOne.value) { if (isGoogleOne.value) {
// Google One tier 样式 const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
const tierColorMap: Record<string, string> = { if (tier === 'google_ai_ultra') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
AI_PREMIUM: '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'
GOOGLE_ONE_STANDARD: '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'
GOOGLE_ONE_BASIC: 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-300', // Backward compatibility
FREE: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300', const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300', if (upper === 'GOOGLE_ONE_UNLIMITED') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-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'
return tierColorMap[creds?.tier_id || ''] || 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
} }
// AI Studio 默认样式:蓝色 // 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' return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
}) })

View File

@@ -241,23 +241,16 @@
<div v-else-if="error" class="text-xs text-red-500"> <div v-else-if="error" class="text-xs text-red-500">
{{ error }} {{ error }}
</div> </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"> <div v-else-if="geminiUsageAvailable" class="space-y-1">
<UsageProgressBar <UsageProgressBar
v-if="usageInfo?.gemini_pro_daily" v-for="bar in geminiUsageBars"
label="Pro" :key="bar.key"
:utilization="usageInfo.gemini_pro_daily.utilization" :label="bar.label"
:resets-at="usageInfo.gemini_pro_daily.resets_at" :utilization="bar.utilization"
:window-stats="usageInfo.gemini_pro_daily.window_stats" :resets-at="bar.resetsAt"
color="indigo" :window-stats="bar.windowStats"
/> :color="bar.color"
<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"
/> />
<p class="mt-1 text-[9px] leading-tight text-gray-400 dark:text-gray-500 italic"> <p class="mt-1 text-[9px] leading-tight text-gray-400 dark:text-gray-500 italic">
* {{ t('admin.accounts.gemini.quotaPolicy.simulatedNote') || 'Simulated quota' }} * {{ t('admin.accounts.gemini.quotaPolicy.simulatedNote') || 'Simulated quota' }}
@@ -288,7 +281,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin' 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 UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue' import AccountQuotaInfo from './AccountQuotaInfo.vue'
@@ -303,16 +296,18 @@ const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null) const usageInfo = ref<AccountUsageInfo | null>(null)
// Show usage windows for OAuth and Setup Token accounts // Show usage windows for OAuth and Setup Token accounts
const showUsageWindows = computed( const showUsageWindows = computed(() => {
() => props.account.type === 'oauth' || props.account.type === 'setup-token' // 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(() => { const shouldFetchUsage = computed(() => {
if (props.account.platform === 'anthropic') { if (props.account.platform === 'anthropic') {
return props.account.type === 'oauth' || props.account.type === 'setup-token' return props.account.type === 'oauth' || props.account.type === 'setup-token'
} }
if (props.account.platform === 'gemini') { if (props.account.platform === 'gemini') {
return props.account.type === 'oauth' return true
} }
if (props.account.platform === 'antigravity') { if (props.account.platform === 'antigravity') {
return props.account.type === 'oauth' return props.account.type === 'oauth'
@@ -322,8 +317,12 @@ const shouldFetchUsage = computed(() => {
const geminiUsageAvailable = computed(() => { const geminiUsageAvailable = computed(() => {
return ( return (
!!usageInfo.value?.gemini_shared_daily ||
!!usageInfo.value?.gemini_pro_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 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 // Gemini 是否为 Code Assist OAuth
const isGeminiCodeAssist = computed(() => { const isGeminiCodeAssist = computed(() => {
if (props.account.platform !== 'gemini') return false 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) return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
}) })
// Gemini 认证类型 + Tier 组合标签(简洁版) const geminiChannelShort = computed((): 'ai studio' | 'gcp' | 'google one' | 'client' | null => {
const geminiAuthTypeLabel = computed(() => { if (props.account.platform !== 'gemini') return null
const creds = props.account.credentials as GeminiCredentials | undefined
const oauthType = creds?.oauth_type
// For API Key accounts, don't show auth type label // API Key accounts are AI Studio.
if (props.account.type !== 'oauth') return null if (props.account.type === 'apikey') return 'ai studio'
if (oauthType === 'google_one') { if (geminiOAuthType.value === 'google_one') return 'google one'
// Google One: show "Google One" + tier if (isGeminiCodeAssist.value) return 'gcp'
const tierMap: Record<string, string> = { if (geminiOAuthType.value === 'ai_studio') return 'client'
AI_PREMIUM: 'AI Premium',
GOOGLE_ONE_STANDARD: 'Standard', // Fallback (unknown legacy data): treat as AI Studio.
GOOGLE_ONE_BASIC: 'Basic', return 'ai studio'
FREE: 'Free', })
GOOGLE_ONE_UNKNOWN: 'Personal',
GOOGLE_ONE_UNLIMITED: 'Unlimited' const geminiUserLevel = computed((): string | null => {
} if (props.account.platform !== 'gemini') return null
const tierLabel = geminiTier.value ? tierMap[geminiTier.value] || 'Personal' : 'Personal'
return `Google One ${tierLabel}` const tier = (geminiTier.value || '').toString().trim()
} else if (oauthType === 'code_assist' || (!oauthType && isGeminiCodeAssist.value)) { const tierLower = tier.toLowerCase()
// Code Assist: show "GCP" + tier const tierUpper = tier.toUpperCase()
const tierMap: Record<string, string> = {
LEGACY: 'Free', // Google One: free / pro / ultra
PRO: 'Pro', if (geminiOAuthType.value === 'google_one') {
ULTRA: 'Ultra' if (tierLower === 'google_one_free') return 'free'
} if (tierLower === 'google_ai_pro') return 'pro'
const tierLabel = geminiTier.value ? tierMap[geminiTier.value] || 'Free' : 'Free' if (tierLower === 'google_ai_ultra') return 'ultra'
return `GCP ${tierLabel}`
} else if (oauthType === 'ai_studio') { // Backward compatibility (legacy tier markers)
// 自定义 OAuth Client: show "Client" (no tier) if (tierUpper === 'AI_PREMIUM' || tierUpper === 'GOOGLE_ONE_STANDARD') return 'pro'
return 'Client' 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 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 账户类型徽章样式(统一样式) // Gemini 账户类型徽章样式(统一样式)
const geminiTierClass = computed(() => { const geminiTierClass = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined // Use channel+level to choose a stable color without depending on raw tier_id variants.
const oauthType = creds?.oauth_type const channel = geminiChannelShort.value
const level = geminiUserLevel.value
// Client (自定义 OAuth): 使用蓝色(与 AI Studio 一致) if (channel === 'client' || channel === 'ai studio') {
if (oauthType === 'ai_studio') {
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300' return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
} }
if (!geminiTier.value) return '' if (channel === 'google one') {
if (level === 'ultra') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
const isGoogleOne = creds?.oauth_type === 'google_one' 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'
if (isGoogleOne) {
// Google One tier 颜色
const colorMap: Record<string, string> = {
AI_PREMIUM: 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300',
GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300',
GOOGLE_ONE_BASIC: 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-300',
FREE: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300'
}
return colorMap[geminiTier.value] || 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
} }
// Code Assist tier 颜色 if (channel === 'gcp') {
switch (geminiTier.value) { if (level === 'enterprise') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
case 'LEGACY': 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'
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 ''
} }
return ''
}) })
// Gemini 配额政策信息 // Gemini 配额政策信息
const geminiQuotaPolicyChannel = computed(() => { const geminiQuotaPolicyChannel = computed(() => {
if (geminiOAuthType.value === 'google_one') {
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.channel')
}
if (isGeminiCodeAssist.value) { 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') return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.channel')
}) })
const geminiQuotaPolicyLimits = computed(() => { const geminiQuotaPolicyLimits = computed(() => {
if (isGeminiCodeAssist.value) { const tierLower = (geminiTier.value || '').toString().trim().toLowerCase()
if (geminiTier.value === 'PRO' || geminiTier.value === 'ULTRA') {
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsPremium') 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') return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsFree')
}) })
const geminiQuotaPolicyDocsUrl = computed(() => { const geminiQuotaPolicyDocsUrl = computed(() => {
if (isGeminiCodeAssist.value) { if (geminiOAuthType.value === 'google_one' || isGeminiCodeAssist.value) {
return 'https://cloud.google.com/products/gemini/code-assist#pricing' return 'https://developers.google.com/gemini-code-assist/resources/quotas'
} }
return 'https://ai.google.dev/pricing' 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(() => { const antigravityTierLabel = computed(() => {
switch (antigravityTier.value) { switch (antigravityTier.value) {

View File

@@ -653,6 +653,41 @@
</div> </div>
</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="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"> <div class="flex items-start gap-3">
<svg <svg
@@ -820,6 +855,16 @@
<p class="input-hint">{{ apiKeyHint }}</p> <p class="input-hint">{{ apiKeyHint }}</p>
</div> </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) --> <!-- Model Restriction Section (不适用于 Gemini) -->
<div v-if="form.platform !== 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600"> <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> <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 geminiAIStudioOAuthEnabled = ref(false)
const showAdvancedOAuth = 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 = { const geminiQuotaDocs = {
codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas', codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas',
aiStudio: 'https://ai.google.dev/pricing', aiStudio: 'https://ai.google.dev/pricing',
@@ -2143,6 +2206,9 @@ const resetForm = () => {
tempUnschedEnabled.value = false tempUnschedEnabled.value = false
tempUnschedRules.value = [] tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
geminiTierGoogleOne.value = 'google_one_free'
geminiTierGcp.value = 'gcp_standard'
geminiTierAIStudio.value = 'aistudio_free'
oauth.resetState() oauth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
geminiOAuth.resetState() geminiOAuth.resetState()
@@ -2184,6 +2250,9 @@ const handleSubmit = async () => {
base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl, base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl,
api_key: apiKeyValue.value.trim() api_key: apiKeyValue.value.trim()
} }
if (form.platform === 'gemini') {
credentials.tier_id = geminiTierAIStudio.value
}
// Add model mapping if configured // Add model mapping if configured
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
@@ -2237,7 +2306,12 @@ const handleGenerateUrl = async () => {
if (form.platform === 'openai') { if (form.platform === 'openai') {
await openaiOAuth.generateAuthUrl(form.proxy_id) await openaiOAuth.generateAuthUrl(form.proxy_id)
} else if (form.platform === 'gemini') { } 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') { } else if (form.platform === 'antigravity') {
await antigravityOAuth.generateAuthUrl(form.proxy_id) await antigravityOAuth.generateAuthUrl(form.proxy_id)
} else { } else {
@@ -2318,7 +2392,8 @@ const handleGeminiExchange = async (authCode: string) => {
sessionId: geminiOAuth.sessionId.value, sessionId: geminiOAuth.sessionId.value,
state: stateToUse, state: stateToUse,
proxyId: form.proxy_id, proxyId: form.proxy_id,
oauthType: geminiOAuthType.value oauthType: geminiOAuthType.value,
tierId: geminiSelectedTier.value
}) })
if (!tokenInfo) return if (!tokenInfo) return

View File

@@ -88,7 +88,35 @@
<!-- Gemini OAuth Type Selection --> <!-- Gemini OAuth Type Selection -->
<fieldset v-if="isGemini" class="border-0 p-0"> <fieldset v-if="isGemini" class="border-0 p-0">
<legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend> <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 <button
type="button" type="button"
@click="handleSelectGeminiOAuthType('code_assist')" @click="handleSelectGeminiOAuthType('code_assist')"
@@ -305,7 +333,7 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State // State
const addMethod = ref<AddMethod>('oauth') 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) const geminiAIStudioOAuthEnabled = ref(false)
// Computed - check platform // Computed - check platform
@@ -367,7 +395,12 @@ watch(
} }
if (isGemini.value) { if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown> 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) { if (isGemini.value) {
geminiOAuth.getCapabilities().then((caps) => { geminiOAuth.getCapabilities().then((caps) => {
@@ -395,7 +428,7 @@ const resetState = () => {
oauthFlowRef.value?.reset() 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) { if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured')) appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
return return
@@ -413,8 +446,10 @@ const handleGenerateUrl = async () => {
if (isOpenAI.value) { if (isOpenAI.value) {
await openaiOAuth.generateAuthUrl(props.account.proxy_id) await openaiOAuth.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) { } 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 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) { } else if (isAntigravity.value) {
await antigravityOAuth.generateAuthUrl(props.account.proxy_id) await antigravityOAuth.generateAuthUrl(props.account.proxy_id)
} else { } else {
@@ -475,7 +510,8 @@ const handleExchangeCode = async () => {
sessionId, sessionId,
state: stateToUse, state: stateToUse,
proxyId: props.account.proxy_id, 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 if (!tokenInfo) return

View File

@@ -38,7 +38,8 @@ export function useGeminiOAuth() {
const generateAuthUrl = async ( const generateAuthUrl = async (
proxyId: number | null | undefined, proxyId: number | null | undefined,
projectId?: string | null, projectId?: string | null,
oauthType?: string oauthType?: string,
tierId?: string
): Promise<boolean> => { ): Promise<boolean> => {
loading.value = true loading.value = true
authUrl.value = '' authUrl.value = ''
@@ -52,6 +53,8 @@ export function useGeminiOAuth() {
const trimmedProjectID = projectId?.trim() const trimmedProjectID = projectId?.trim()
if (trimmedProjectID) payload.project_id = trimmedProjectID if (trimmedProjectID) payload.project_id = trimmedProjectID
if (oauthType) payload.oauth_type = oauthType 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) const response = await adminAPI.gemini.generateAuthUrl(payload as any)
authUrl.value = response.auth_url authUrl.value = response.auth_url
@@ -73,6 +76,7 @@ export function useGeminiOAuth() {
state: string state: string
proxyId?: number | null proxyId?: number | null
oauthType?: string oauthType?: string
tierId?: string
}): Promise<GeminiTokenInfo | null> => { }): Promise<GeminiTokenInfo | null> => {
const code = params.code?.trim() const code = params.code?.trim()
if (!code || !params.sessionId || !params.state) { if (!code || !params.sessionId || !params.state) {
@@ -91,6 +95,8 @@ export function useGeminiOAuth() {
} }
if (params.proxyId) payload.proxy_id = params.proxyId if (params.proxyId) payload.proxy_id = params.proxyId
if (params.oauthType) payload.oauth_type = params.oauthType 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) const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any)
return tokenInfo as GeminiTokenInfo return tokenInfo as GeminiTokenInfo

View File

@@ -1257,6 +1257,25 @@ export default {
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
baseUrlHint: 'Leave default for official Gemini API', baseUrlHint: 'Leave default for official Gemini API',
apiKeyHint: 'Your Gemini API Key (starts with AIza)', 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: { accountType: {
oauthTitle: 'OAuth (Gemini)', oauthTitle: 'OAuth (Gemini)',
oauthDesc: 'Authorize with your Google account and choose an OAuth type.', oauthDesc: 'Authorize with your Google account and choose an OAuth type.',
@@ -1317,6 +1336,17 @@ export default {
}, },
simulatedNote: 'Simulated quota, for reference only', simulatedNote: 'Simulated quota, for reference only',
rows: { 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: { cli: {
channel: 'Gemini CLI (Official Google Login / Code Assist)', channel: 'Gemini CLI (Official Google Login / Code Assist)',
free: 'Free Google Account', free: 'Free Google Account',
@@ -1334,7 +1364,7 @@ export default {
free: 'No billing (free tier)', free: 'No billing (free tier)',
paid: 'Billing enabled (pay-as-you-go)', paid: 'Billing enabled (pay-as-you-go)',
limitsFree: 'RPD 50; RPM 2 (Pro) / 15 (Flash)', 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: { customOAuth: {
channel: 'Custom OAuth Client (GCP)', channel: 'Custom OAuth Client (GCP)',

View File

@@ -1395,6 +1395,24 @@ export default {
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API不进行模型限制或映射。', modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API不进行模型限制或映射。',
baseUrlHint: '留空使用官方 Gemini API', baseUrlHint: '留空使用官方 Gemini API',
apiKeyHint: '您的 Gemini API Key以 AIza 开头)', apiKeyHint: '您的 Gemini API Key以 AIza 开头)',
tier: {
label: 'Tier配额等级',
hint: '提示:系统会优先尝试自动识别 Tier若自动识别不可用或失败则使用你选择的 Tier 作为回退(本地模拟配额)。',
aiStudioHint: 'AI Studio 的配额是按模型分别限流Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。',
googleOne: {
free: 'Google One Free1000 RPD / 60 RPM共享池',
pro: 'Google AI Pro1500 RPD / 120 RPM共享池',
ultra: 'Google AI Ultra2000 RPD / 120 RPM共享池'
},
gcp: {
standard: 'GCP Standard1500 RPD / 120 RPM共享池',
enterprise: 'GCP Enterprise2000 RPD / 120 RPM共享池'
},
aiStudio: {
free: 'AI Studio Free TierPro: 50 RPD / 2 RPMFlash: 1500 RPD / 15 RPM',
paid: 'AI Studio Pay-as-you-goPro: ∞ RPD / 1000 RPMFlash: ∞ RPD / 2000 RPM'
}
},
accountType: { accountType: {
oauthTitle: 'OAuth 授权Gemini', oauthTitle: 'OAuth 授权Gemini',
oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。', oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。',
@@ -1454,6 +1472,17 @@ export default {
}, },
simulatedNote: '本地模拟配额,仅供参考', simulatedNote: '本地模拟配额,仅供参考',
rows: { 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: { cli: {
channel: 'Gemini CLI官方 Google 登录 / Code Assist', channel: 'Gemini CLI官方 Google 登录 / Code Assist',
free: '免费 Google 账号', free: '免费 Google 账号',
@@ -1471,7 +1500,7 @@ export default {
free: '未绑卡(免费层)', free: '未绑卡(免费层)',
paid: '已绑卡(按量付费)', paid: '已绑卡(按量付费)',
limitsFree: 'RPD 50RPM 2Pro/ 15Flash', limitsFree: 'RPD 50RPM 2Pro/ 15Flash',
limitsPaid: 'RPD 不限RPM 1000+(按模型配额)' limitsPaid: 'RPD 不限RPM 1000Pro/ 2000Flash(按模型配额)'
}, },
customOAuth: { customOAuth: {
channel: 'Custom OAuth ClientGCP', channel: 'Custom OAuth ClientGCP',

View File

@@ -322,8 +322,19 @@ export interface GeminiCredentials {
// OAuth authentication // OAuth authentication
access_token?: string access_token?: string
refresh_token?: string refresh_token?: string
oauth_type?: 'code_assist' | 'ai_studio' | string oauth_type?: 'code_assist' | 'google_one' | 'ai_studio' | string
tier_id?: 'LEGACY' | 'PRO' | 'ULTRA' | 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 project_id?: string
token_type?: string token_type?: string
scope?: string scope?: string
@@ -397,6 +408,8 @@ export interface UsageProgress {
resets_at: string | null resets_at: string | null
remaining_seconds: number remaining_seconds: number
window_stats?: WindowStats | null // 窗口期统计(从窗口开始到当前的使用量) window_stats?: WindowStats | null // 窗口期统计(从窗口开始到当前的使用量)
used_requests?: number
limit_requests?: number
} }
// Antigravity 单个模型的配额信息 // Antigravity 单个模型的配额信息
@@ -410,8 +423,12 @@ export interface AccountUsageInfo {
five_hour: UsageProgress | null five_hour: UsageProgress | null
seven_day: UsageProgress | null seven_day: UsageProgress | null
seven_day_sonnet: UsageProgress | null seven_day_sonnet: UsageProgress | null
gemini_shared_daily?: UsageProgress | null
gemini_pro_daily?: UsageProgress | null gemini_pro_daily?: UsageProgress | null
gemini_flash_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 antigravity_quota?: Record<string, AntigravityModelQuota> | null
} }