Merge branch 'feature/gemini-quota' (PR #113)

feat: Gemini 配额模拟和限流功能

主要变更:
- 新增 GeminiQuotaService 实现基于 Tier 的配额管理
- RateLimitService 增加 PreCheckUsage 预检查功能
- gemini_oauth_service 改进 tier_id 处理逻辑(向后兼容)
- 前端新增配额可视化组件 (AccountQuotaInfo.vue)
- 数据库迁移: 为现有 Code Assist 账号添加默认 tier_id

技术细节:
- 支持 LEGACY/PRO/ULTRA 三种配额等级
- 配额策略可通过配置文件或数据库设置覆盖
- fetchProjectID 返回值保留 tierID(即使 projectID 获取失败)
- 删除冗余类型别名 ClaudeCustomToolSpec
This commit is contained in:
shaw
2026-01-01 10:58:11 +08:00
30 changed files with 1802 additions and 105 deletions

1
.gitignore vendored
View File

@@ -77,6 +77,7 @@ temp/
*.temp
*.log
*.bak
.cache/
# ===================
# 构建产物

View File

@@ -87,9 +87,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, configConfig)
rateLimitService := service.NewRateLimitService(accountRepository, configConfig)
geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository)
rateLimitService := service.NewRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService)
claudeUsageFetcher := repository.NewClaudeUsageFetcher()
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher)
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService)
geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService)
gatewayCache := repository.NewGatewayCache(redisClient)

View File

@@ -43,6 +43,7 @@ type Config struct {
type GeminiConfig struct {
OAuth GeminiOAuthConfig `mapstructure:"oauth"`
Quota GeminiQuotaConfig `mapstructure:"quota"`
}
type GeminiOAuthConfig struct {
@@ -51,6 +52,17 @@ type GeminiOAuthConfig struct {
Scopes string `mapstructure:"scopes"`
}
type GeminiQuotaConfig struct {
Tiers map[string]GeminiTierQuotaConfig `mapstructure:"tiers"`
Policy string `mapstructure:"policy"`
}
type GeminiTierQuotaConfig struct {
ProRPD *int64 `mapstructure:"pro_rpd" json:"pro_rpd"`
FlashRPD *int64 `mapstructure:"flash_rpd" json:"flash_rpd"`
CooldownMinutes *int `mapstructure:"cooldown_minutes" json:"cooldown_minutes"`
}
// TokenRefreshConfig OAuth token自动刷新配置
type TokenRefreshConfig struct {
// 是否启用自动刷新
@@ -352,6 +364,7 @@ func setDefaults() {
viper.SetDefault("gemini.oauth.client_id", "")
viper.SetDefault("gemini.oauth.client_secret", "")
viper.SetDefault("gemini.oauth.scopes", "")
viper.SetDefault("gemini.quota.policy", "")
}
func (c *Config) Validate() error {

View File

@@ -96,7 +96,7 @@ func TestBuildTools_CustomTypeTools(t *testing.T) {
{
Type: "custom",
Name: "mcp_tool",
Custom: &ClaudeCustomToolSpec{
Custom: &CustomToolSpec{
Description: "MCP tool description",
InputSchema: map[string]any{
"type": "object",
@@ -121,7 +121,7 @@ func TestBuildTools_CustomTypeTools(t *testing.T) {
{
Type: "custom",
Name: "custom_tool",
Custom: &ClaudeCustomToolSpec{
Custom: &CustomToolSpec{
Description: "Custom tool",
InputSchema: map[string]any{"type": "object"},
},
@@ -148,7 +148,7 @@ func TestBuildTools_CustomTypeTools(t *testing.T) {
{
Type: "custom",
Name: "invalid_custom",
Custom: &ClaudeCustomToolSpec{
Custom: &CustomToolSpec{
Description: "Invalid",
// InputSchema 为 nil
},

View File

@@ -127,7 +127,15 @@ func applyMigrationsFS(ctx context.Context, db *sql.DB, fsys fs.FS) error {
if existing != checksum {
// 校验和不匹配意味着迁移文件在应用后被修改,这是危险的。
// 正确的做法是创建新的迁移文件来进行变更。
return fmt.Errorf("migration %s checksum mismatch (db=%s file=%s)", name, existing, checksum)
return fmt.Errorf(
"migration %s checksum mismatch (db=%s file=%s)\n"+
"This means the migration file was modified after being applied to the database.\n"+
"Solutions:\n"+
" 1. Revert to original: git log --oneline -- migrations/%s && git checkout <commit> -- migrations/%s\n"+
" 2. For new changes, create a new migration file instead of modifying existing ones\n"+
"Note: Modifying applied migrations breaks the immutability principle and can cause inconsistencies across environments",
name, existing, checksum, name, name,
)
}
continue // 迁移已应用且校验和匹配,跳过
}

View File

@@ -3,6 +3,7 @@ package service
import (
"encoding/json"
"strconv"
"strings"
"time"
)
@@ -78,6 +79,36 @@ func (a *Account) IsGemini() bool {
return a.Platform == PlatformGemini
}
func (a *Account) GeminiOAuthType() string {
if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
return ""
}
oauthType := strings.TrimSpace(a.GetCredential("oauth_type"))
if oauthType == "" && strings.TrimSpace(a.GetCredential("project_id")) != "" {
return "code_assist"
}
return oauthType
}
func (a *Account) GeminiTierID() string {
tierID := strings.TrimSpace(a.GetCredential("tier_id"))
if tierID == "" {
return ""
}
return strings.ToUpper(tierID)
}
func (a *Account) IsGeminiCodeAssist() bool {
if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
return false
}
oauthType := a.GeminiOAuthType()
if oauthType == "" {
return strings.TrimSpace(a.GetCredential("project_id")) != ""
}
return oauthType == "code_assist"
}
func (a *Account) CanGetUsage() bool {
return a.Type == AccountTypeOAuth
}

View File

@@ -93,10 +93,12 @@ type UsageProgress struct {
// UsageInfo 账号使用量信息
type UsageInfo struct {
UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口
SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口
SevenDaySonnet *UsageProgress `json:"seven_day_sonnet,omitempty"` // 7天Sonnet窗口
UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口
SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口
SevenDaySonnet *UsageProgress `json:"seven_day_sonnet,omitempty"` // 7天Sonnet窗口
GeminiProDaily *UsageProgress `json:"gemini_pro_daily,omitempty"` // Gemini Pro 日配额
GeminiFlashDaily *UsageProgress `json:"gemini_flash_daily,omitempty"` // Gemini Flash 日配额
}
// ClaudeUsageResponse Anthropic API返回的usage结构
@@ -122,17 +124,19 @@ type ClaudeUsageFetcher interface {
// AccountUsageService 账号使用量查询服务
type AccountUsageService struct {
accountRepo AccountRepository
usageLogRepo UsageLogRepository
usageFetcher ClaudeUsageFetcher
accountRepo AccountRepository
usageLogRepo UsageLogRepository
usageFetcher ClaudeUsageFetcher
geminiQuotaService *GeminiQuotaService
}
// NewAccountUsageService 创建AccountUsageService实例
func NewAccountUsageService(accountRepo AccountRepository, usageLogRepo UsageLogRepository, usageFetcher ClaudeUsageFetcher) *AccountUsageService {
func NewAccountUsageService(accountRepo AccountRepository, usageLogRepo UsageLogRepository, usageFetcher ClaudeUsageFetcher, geminiQuotaService *GeminiQuotaService) *AccountUsageService {
return &AccountUsageService{
accountRepo: accountRepo,
usageLogRepo: usageLogRepo,
usageFetcher: usageFetcher,
accountRepo: accountRepo,
usageLogRepo: usageLogRepo,
usageFetcher: usageFetcher,
geminiQuotaService: geminiQuotaService,
}
}
@@ -146,6 +150,10 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
return nil, fmt.Errorf("get account failed: %w", err)
}
if account.Platform == PlatformGemini {
return s.getGeminiUsage(ctx, account)
}
// 只有oauth类型账号可以通过API获取usage有profile scope
if account.CanGetUsage() {
var apiResp *ClaudeUsageResponse
@@ -192,6 +200,36 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
return nil, fmt.Errorf("account type %s does not support usage query", account.Type)
}
func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Account) (*UsageInfo, error) {
now := time.Now()
usage := &UsageInfo{
UpdatedAt: &now,
}
if s.geminiQuotaService == nil || s.usageLogRepo == nil {
return usage, nil
}
quota, ok := s.geminiQuotaService.QuotaForAccount(ctx, account)
if !ok {
return usage, nil
}
start := geminiDailyWindowStart(now)
stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
if err != nil {
return nil, fmt.Errorf("get gemini usage stats failed: %w", err)
}
totals := geminiAggregateUsage(stats)
resetAt := geminiDailyResetTime(now)
usage.GeminiProDaily = buildGeminiUsageProgress(totals.ProRequests, quota.ProRPD, resetAt, totals.ProTokens, totals.ProCost, now)
usage.GeminiFlashDaily = buildGeminiUsageProgress(totals.FlashRequests, quota.FlashRPD, resetAt, totals.FlashTokens, totals.FlashCost, now)
return usage, nil
}
// addWindowStats 为 usage 数据添加窗口期统计
// 使用独立缓存1 分钟),与 API 缓存分离
func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Account, usage *UsageInfo) {
@@ -388,3 +426,25 @@ func (s *AccountUsageService) estimateSetupTokenUsage(account *Account) *UsageIn
// Setup Token无法获取7d数据
return info
}
func buildGeminiUsageProgress(used, limit int64, resetAt time.Time, tokens int64, cost float64, now time.Time) *UsageProgress {
if limit <= 0 {
return nil
}
utilization := (float64(used) / float64(limit)) * 100
remainingSeconds := int(resetAt.Sub(now).Seconds())
if remainingSeconds < 0 {
remainingSeconds = 0
}
resetCopy := resetAt
return &UsageProgress{
Utilization: utilization,
ResetsAt: &resetCopy,
RemainingSeconds: remainingSeconds,
WindowStats: &WindowStats{
Requests: used,
Tokens: tokens,
Cost: cost,
},
}
}

View File

@@ -91,6 +91,9 @@ const (
// 管理员 API Key
SettingKeyAdminApiKey = "admin_api_key" // 全局管理员 API Key用于外部系统集成
// Gemini 配额策略JSON
SettingKeyGeminiQuotaPolicy = "gemini_quota_policy"
)
// Admin API Key prefix (distinct from user "sk-" keys)

View File

@@ -116,8 +116,20 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co
valid = true
}
if valid {
_ = s.cache.RefreshSessionTTL(ctx, cacheKey, geminiStickySessionTTL)
return account, nil
usable := true
if s.rateLimitService != nil && requestedModel != "" {
ok, err := s.rateLimitService.PreCheckUsage(ctx, account, requestedModel)
if err != nil {
log.Printf("[Gemini PreCheck] Account %d precheck error: %v", account.ID, err)
}
if !ok {
usable = false
}
}
if usable {
_ = s.cache.RefreshSessionTTL(ctx, cacheKey, geminiStickySessionTTL)
return account, nil
}
}
}
}
@@ -157,6 +169,15 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
continue
}
if s.rateLimitService != nil && requestedModel != "" {
ok, err := s.rateLimitService.PreCheckUsage(ctx, acc, requestedModel)
if err != nil {
log.Printf("[Gemini PreCheck] Account %d precheck error: %v", acc.ID, err)
}
if !ok {
continue
}
}
if selected == nil {
selected = acc
continue
@@ -1886,13 +1907,44 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont
if statusCode != 429 {
return
}
oauthType := account.GeminiOAuthType()
tierID := account.GeminiTierID()
projectID := strings.TrimSpace(account.GetCredential("project_id"))
isCodeAssist := account.IsGeminiCodeAssist()
resetAt := ParseGeminiRateLimitResetTime(body)
if resetAt == nil {
ra := time.Now().Add(5 * time.Minute)
// 根据账号类型使用不同的默认重置时间
var ra time.Time
if isCodeAssist {
// Code Assist: fallback cooldown by tier
cooldown := geminiCooldownForTier(tierID)
if s.rateLimitService != nil {
cooldown = s.rateLimitService.GeminiCooldown(ctx, account)
}
ra = time.Now().Add(cooldown)
log.Printf("[Gemini 429] Account %d (Code Assist, tier=%s, project=%s) rate limited, cooldown=%v", account.ID, tierID, projectID, time.Until(ra).Truncate(time.Second))
} else {
// API Key / AI Studio OAuth: PST 午夜
if ts := nextGeminiDailyResetUnix(); ts != nil {
ra = time.Unix(*ts, 0)
log.Printf("[Gemini 429] Account %d (API Key/AI Studio, type=%s) rate limited, reset at PST midnight (%v)", account.ID, account.Type, ra)
} else {
// 兜底5 分钟
ra = time.Now().Add(5 * time.Minute)
log.Printf("[Gemini 429] Account %d rate limited, fallback to 5min", account.ID)
}
}
_ = s.accountRepo.SetRateLimited(ctx, account.ID, ra)
return
}
_ = s.accountRepo.SetRateLimited(ctx, account.ID, time.Unix(*resetAt, 0))
// 使用解析到的重置时间
resetTime := time.Unix(*resetAt, 0)
_ = s.accountRepo.SetRateLimited(ctx, account.ID, resetTime)
log.Printf("[Gemini 429] Account %d rate limited until %v (oauth_type=%s, tier=%s)",
account.ID, resetTime, oauthType, tierID)
}
// ParseGeminiRateLimitResetTime 解析 Gemini 格式的 429 响应,返回重置时间的 Unix 时间戳
@@ -1948,16 +2000,7 @@ func looksLikeGeminiDailyQuota(message string) bool {
}
func nextGeminiDailyResetUnix() *int64 {
loc, err := time.LoadLocation("America/Los_Angeles")
if err != nil {
// Fallback: PST without DST.
loc = time.FixedZone("PST", -8*3600)
}
now := time.Now().In(loc)
reset := time.Date(now.Year(), now.Month(), now.Day(), 0, 5, 0, 0, loc)
if !reset.After(now) {
reset = reset.Add(24 * time.Hour)
}
reset := geminiDailyResetTime(time.Now())
ts := reset.Unix()
return &ts
}

View File

@@ -259,8 +259,15 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
sessionProjectID := strings.TrimSpace(session.ProjectID)
s.sessionStore.Delete(input.SessionID)
// 计算过期时间减去 5 分钟安全时间窗口,考虑网络延迟和时钟偏差
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
// 计算过期时间减去 5 分钟安全时间窗口考虑网络延迟和时钟偏差
// 同时设置下界保护,防止 expires_in 过小导致过去时间(引发刷新风暴)
const safetyWindow = 300 // 5 minutes
const minTTL = 30 // minimum 30 seconds
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - safetyWindow
minExpiresAt := time.Now().Unix() + minTTL
if expiresAt < minExpiresAt {
expiresAt = minExpiresAt
}
projectID := sessionProjectID
var tierID string
@@ -275,10 +282,22 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
// 记录警告但不阻断流程,允许后续补充 project_id
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch project_id during token exchange: %v\n", err)
}
} else {
// 用户手动填了 project_id仍需调用 LoadCodeAssist 获取 tierID
_, fetchedTierID, err := s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
if err != nil {
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch tierID: %v\n", err)
} else {
tierID = fetchedTierID
}
}
if strings.TrimSpace(projectID) == "" {
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 缺失时使用默认值
if tierID == "" {
tierID = "LEGACY"
}
}
return &GeminiTokenInfo{
@@ -308,8 +327,15 @@ func (s *GeminiOAuthService) RefreshToken(ctx context.Context, oauthType, refres
tokenResp, err := s.oauthClient.RefreshToken(ctx, oauthType, refreshToken, proxyURL)
if err == nil {
// 计算过期时间减去 5 分钟安全时间窗口,考虑网络延迟和时钟偏差
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
// 计算过期时间减去 5 分钟安全时间窗口考虑网络延迟和时钟偏差
// 同时设置下界保护,防止 expires_in 过小导致过去时间(引发刷新风暴)
const safetyWindow = 300 // 5 minutes
const minTTL = 30 // minimum 30 seconds
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - safetyWindow
minExpiresAt := time.Now().Unix() + minTTL
if expiresAt < minExpiresAt {
expiresAt = minExpiresAt
}
return &GeminiTokenInfo{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
@@ -396,19 +422,39 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
tokenInfo.ProjectID = existingProjectID
}
// 尝试从账号凭证获取 tierID向后兼容
existingTierID := strings.TrimSpace(account.GetCredential("tier_id"))
// For Code Assist, project_id is required. Auto-detect if missing.
// For AI Studio OAuth, project_id is optional and should not block refresh.
if oauthType == "code_assist" && strings.TrimSpace(tokenInfo.ProjectID) == "" {
projectID, tierID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL)
if err != nil {
return nil, fmt.Errorf("failed to auto-detect project_id: %w", err)
if oauthType == "code_assist" {
// 先设置默认值或保留旧值,确保 tier_id 始终有值
if existingTierID != "" {
tokenInfo.TierID = existingTierID
} else {
tokenInfo.TierID = "LEGACY" // 默认值
}
projectID = strings.TrimSpace(projectID)
if projectID == "" {
// 尝试自动探测 project_id 和 tier_id
needDetect := strings.TrimSpace(tokenInfo.ProjectID) == "" || existingTierID == ""
if needDetect {
projectID, tierID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL)
if err != nil {
fmt.Printf("[GeminiOAuth] Warning: failed to auto-detect project/tier: %v\n", err)
} else {
if strings.TrimSpace(tokenInfo.ProjectID) == "" && projectID != "" {
tokenInfo.ProjectID = projectID
}
// 只有当原来没有 tier_id 且探测成功时才更新
if existingTierID == "" && tierID != "" {
tokenInfo.TierID = tierID
}
}
}
if strings.TrimSpace(tokenInfo.ProjectID) == "" {
return nil, fmt.Errorf("failed to auto-detect project_id: empty result")
}
tokenInfo.ProjectID = projectID
tokenInfo.TierID = tierID
}
return tokenInfo, nil
@@ -466,9 +512,6 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
return strings.TrimSpace(loadResp.CloudAICompanionProject), tierID, nil
}
// Pick tier from allowedTiers; if no default tier is marked, pick the first non-empty tier ID.
// (tierID already extracted above, reuse it)
req := &geminicli.OnboardUserRequest{
TierID: tierID,
Metadata: geminicli.LoadCodeAssistMetadata{
@@ -487,7 +530,7 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
if fbErr == nil && strings.TrimSpace(fallback) != "" {
return strings.TrimSpace(fallback), tierID, nil
}
return "", "", err
return "", tierID, err
}
if resp.Done {
if resp.Response != nil && resp.Response.CloudAICompanionProject != nil {
@@ -505,7 +548,7 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
if fbErr == nil && strings.TrimSpace(fallback) != "" {
return strings.TrimSpace(fallback), tierID, nil
}
return "", "", errors.New("onboardUser completed but no project_id returned")
return "", tierID, errors.New("onboardUser completed but no project_id returned")
}
time.Sleep(2 * time.Second)
}
@@ -515,9 +558,9 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
return strings.TrimSpace(fallback), tierID, nil
}
if loadErr != nil {
return "", "", fmt.Errorf("loadCodeAssist failed (%v) and onboardUser timeout after %d attempts", loadErr, maxAttempts)
return "", tierID, fmt.Errorf("loadCodeAssist failed (%v) and onboardUser timeout after %d attempts", loadErr, maxAttempts)
}
return "", "", fmt.Errorf("onboardUser timeout after %d attempts", maxAttempts)
return "", tierID, fmt.Errorf("onboardUser timeout after %d attempts", maxAttempts)
}
type googleCloudProject struct {

View File

@@ -0,0 +1,268 @@
package service
import (
"context"
"encoding/json"
"errors"
"log"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
)
type geminiModelClass string
const (
geminiModelPro geminiModelClass = "pro"
geminiModelFlash geminiModelClass = "flash"
)
type GeminiDailyQuota struct {
ProRPD int64
FlashRPD int64
}
type GeminiTierPolicy struct {
Quota GeminiDailyQuota
Cooldown time.Duration
}
type GeminiQuotaPolicy struct {
tiers map[string]GeminiTierPolicy
}
type GeminiUsageTotals struct {
ProRequests int64
FlashRequests int64
ProTokens int64
FlashTokens int64
ProCost float64
FlashCost float64
}
const geminiQuotaCacheTTL = time.Minute
type geminiQuotaOverrides struct {
Tiers map[string]config.GeminiTierQuotaConfig `json:"tiers"`
}
type GeminiQuotaService struct {
cfg *config.Config
settingRepo SettingRepository
mu sync.Mutex
cachedAt time.Time
policy *GeminiQuotaPolicy
}
func NewGeminiQuotaService(cfg *config.Config, settingRepo SettingRepository) *GeminiQuotaService {
return &GeminiQuotaService{
cfg: cfg,
settingRepo: settingRepo,
}
}
func (s *GeminiQuotaService) Policy(ctx context.Context) *GeminiQuotaPolicy {
if s == nil {
return newGeminiQuotaPolicy()
}
now := time.Now()
s.mu.Lock()
if s.policy != nil && now.Sub(s.cachedAt) < geminiQuotaCacheTTL {
policy := s.policy
s.mu.Unlock()
return policy
}
s.mu.Unlock()
policy := newGeminiQuotaPolicy()
if s.cfg != nil {
policy.ApplyOverrides(s.cfg.Gemini.Quota.Tiers)
if strings.TrimSpace(s.cfg.Gemini.Quota.Policy) != "" {
var overrides geminiQuotaOverrides
if err := json.Unmarshal([]byte(s.cfg.Gemini.Quota.Policy), &overrides); err != nil {
log.Printf("gemini quota: parse config policy failed: %v", err)
} else {
policy.ApplyOverrides(overrides.Tiers)
}
}
}
if s.settingRepo != nil {
value, err := s.settingRepo.GetValue(ctx, SettingKeyGeminiQuotaPolicy)
if err != nil && !errors.Is(err, ErrSettingNotFound) {
log.Printf("gemini quota: load setting failed: %v", err)
} else if strings.TrimSpace(value) != "" {
var overrides geminiQuotaOverrides
if err := json.Unmarshal([]byte(value), &overrides); err != nil {
log.Printf("gemini quota: parse setting failed: %v", err)
} else {
policy.ApplyOverrides(overrides.Tiers)
}
}
}
s.mu.Lock()
s.policy = policy
s.cachedAt = now
s.mu.Unlock()
return policy
}
func (s *GeminiQuotaService) QuotaForAccount(ctx context.Context, account *Account) (GeminiDailyQuota, bool) {
if account == nil || !account.IsGeminiCodeAssist() {
return GeminiDailyQuota{}, false
}
policy := s.Policy(ctx)
return policy.QuotaForTier(account.GeminiTierID())
}
func (s *GeminiQuotaService) CooldownForTier(ctx context.Context, tierID string) time.Duration {
policy := s.Policy(ctx)
return policy.CooldownForTier(tierID)
}
func newGeminiQuotaPolicy() *GeminiQuotaPolicy {
return &GeminiQuotaPolicy{
tiers: map[string]GeminiTierPolicy{
"LEGACY": {Quota: GeminiDailyQuota{ProRPD: 50, FlashRPD: 1500}, Cooldown: 30 * time.Minute},
"PRO": {Quota: GeminiDailyQuota{ProRPD: 1500, FlashRPD: 4000}, Cooldown: 5 * time.Minute},
"ULTRA": {Quota: GeminiDailyQuota{ProRPD: 2000, FlashRPD: 0}, Cooldown: 5 * time.Minute},
},
}
}
func (p *GeminiQuotaPolicy) ApplyOverrides(tiers map[string]config.GeminiTierQuotaConfig) {
if p == nil || len(tiers) == 0 {
return
}
for rawID, override := range tiers {
tierID := normalizeGeminiTierID(rawID)
if tierID == "" {
continue
}
policy, ok := p.tiers[tierID]
if !ok {
policy = GeminiTierPolicy{Cooldown: 5 * time.Minute}
}
if override.ProRPD != nil {
policy.Quota.ProRPD = clampGeminiQuotaInt64(*override.ProRPD)
}
if override.FlashRPD != nil {
policy.Quota.FlashRPD = clampGeminiQuotaInt64(*override.FlashRPD)
}
if override.CooldownMinutes != nil {
minutes := clampGeminiQuotaInt(*override.CooldownMinutes)
policy.Cooldown = time.Duration(minutes) * time.Minute
}
p.tiers[tierID] = policy
}
}
func (p *GeminiQuotaPolicy) QuotaForTier(tierID string) (GeminiDailyQuota, bool) {
policy, ok := p.policyForTier(tierID)
if !ok {
return GeminiDailyQuota{}, false
}
return policy.Quota, true
}
func (p *GeminiQuotaPolicy) CooldownForTier(tierID string) time.Duration {
policy, ok := p.policyForTier(tierID)
if ok && policy.Cooldown > 0 {
return policy.Cooldown
}
return 5 * time.Minute
}
func (p *GeminiQuotaPolicy) policyForTier(tierID string) (GeminiTierPolicy, bool) {
if p == nil {
return GeminiTierPolicy{}, false
}
normalized := normalizeGeminiTierID(tierID)
if normalized == "" {
normalized = "LEGACY"
}
if policy, ok := p.tiers[normalized]; ok {
return policy, true
}
policy, ok := p.tiers["LEGACY"]
return policy, ok
}
func normalizeGeminiTierID(tierID string) string {
return strings.ToUpper(strings.TrimSpace(tierID))
}
func clampGeminiQuotaInt64(value int64) int64 {
if value < 0 {
return 0
}
return value
}
func clampGeminiQuotaInt(value int) int {
if value < 0 {
return 0
}
return value
}
func geminiCooldownForTier(tierID string) time.Duration {
policy := newGeminiQuotaPolicy()
return policy.CooldownForTier(tierID)
}
func geminiModelClassFromName(model string) geminiModelClass {
name := strings.ToLower(strings.TrimSpace(model))
if strings.Contains(name, "flash") || strings.Contains(name, "lite") {
return geminiModelFlash
}
return geminiModelPro
}
func geminiAggregateUsage(stats []usagestats.ModelStat) GeminiUsageTotals {
var totals GeminiUsageTotals
for _, stat := range stats {
switch geminiModelClassFromName(stat.Model) {
case geminiModelFlash:
totals.FlashRequests += stat.Requests
totals.FlashTokens += stat.TotalTokens
totals.FlashCost += stat.ActualCost
default:
totals.ProRequests += stat.Requests
totals.ProTokens += stat.TotalTokens
totals.ProCost += stat.ActualCost
}
}
return totals
}
func geminiQuotaLocation() *time.Location {
loc, err := time.LoadLocation("America/Los_Angeles")
if err != nil {
return time.FixedZone("PST", -8*3600)
}
return loc
}
func geminiDailyWindowStart(now time.Time) time.Time {
loc := geminiQuotaLocation()
localNow := now.In(loc)
return time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc)
}
func geminiDailyResetTime(now time.Time) time.Time {
loc := geminiQuotaLocation()
localNow := now.In(loc)
start := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc)
reset := start.Add(24 * time.Hour)
if !reset.After(localNow) {
reset = reset.Add(24 * time.Hour)
}
return reset
}

View File

@@ -118,6 +118,7 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
return accessToken, nil
}
detected = strings.TrimSpace(detected)
tierID = strings.TrimSpace(tierID)
if detected != "" {
if account.Credentials == nil {
account.Credentials = make(map[string]any)

View File

@@ -5,6 +5,8 @@ import (
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
@@ -12,15 +14,30 @@ import (
// RateLimitService 处理限流和过载状态管理
type RateLimitService struct {
accountRepo AccountRepository
cfg *config.Config
accountRepo AccountRepository
usageRepo UsageLogRepository
cfg *config.Config
geminiQuotaService *GeminiQuotaService
usageCacheMu sync.Mutex
usageCache map[int64]*geminiUsageCacheEntry
}
type geminiUsageCacheEntry struct {
windowStart time.Time
cachedAt time.Time
totals GeminiUsageTotals
}
const geminiPrecheckCacheTTL = time.Minute
// NewRateLimitService 创建RateLimitService实例
func NewRateLimitService(accountRepo AccountRepository, cfg *config.Config) *RateLimitService {
func NewRateLimitService(accountRepo AccountRepository, usageRepo UsageLogRepository, cfg *config.Config, geminiQuotaService *GeminiQuotaService) *RateLimitService {
return &RateLimitService{
accountRepo: accountRepo,
cfg: cfg,
accountRepo: accountRepo,
usageRepo: usageRepo,
cfg: cfg,
geminiQuotaService: geminiQuotaService,
usageCache: make(map[int64]*geminiUsageCacheEntry),
}
}
@@ -62,6 +79,106 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
}
}
// PreCheckUsage proactively checks local quota before dispatching a request.
// Returns false when the account should be skipped.
func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account, requestedModel string) (bool, error) {
if account == nil || !account.IsGeminiCodeAssist() || strings.TrimSpace(requestedModel) == "" {
return true, nil
}
if s.usageRepo == nil || s.geminiQuotaService == nil {
return true, nil
}
quota, ok := s.geminiQuotaService.QuotaForAccount(ctx, account)
if !ok {
return true, nil
}
var limit int64
switch geminiModelClassFromName(requestedModel) {
case geminiModelFlash:
limit = quota.FlashRPD
default:
limit = quota.ProRPD
}
if limit <= 0 {
return true, nil
}
now := time.Now()
start := geminiDailyWindowStart(now)
totals, ok := s.getGeminiUsageTotals(account.ID, start, now)
if !ok {
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
if err != nil {
return true, err
}
totals = geminiAggregateUsage(stats)
s.setGeminiUsageTotals(account.ID, start, now, totals)
}
var used int64
switch geminiModelClassFromName(requestedModel) {
case geminiModelFlash:
used = totals.FlashRequests
default:
used = totals.ProRequests
}
if used >= limit {
resetAt := geminiDailyResetTime(now)
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
log.Printf("SetRateLimited failed for account %d: %v", account.ID, err)
}
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
}
func (s *RateLimitService) getGeminiUsageTotals(accountID int64, windowStart, now time.Time) (GeminiUsageTotals, bool) {
s.usageCacheMu.Lock()
defer s.usageCacheMu.Unlock()
if s.usageCache == nil {
return GeminiUsageTotals{}, false
}
entry, ok := s.usageCache[accountID]
if !ok || entry == nil {
return GeminiUsageTotals{}, false
}
if !entry.windowStart.Equal(windowStart) {
return GeminiUsageTotals{}, false
}
if now.Sub(entry.cachedAt) >= geminiPrecheckCacheTTL {
return GeminiUsageTotals{}, false
}
return entry.totals, true
}
func (s *RateLimitService) setGeminiUsageTotals(accountID int64, windowStart, now time.Time, totals GeminiUsageTotals) {
s.usageCacheMu.Lock()
defer s.usageCacheMu.Unlock()
if s.usageCache == nil {
s.usageCache = make(map[int64]*geminiUsageCacheEntry)
}
s.usageCache[accountID] = &geminiUsageCacheEntry{
windowStart: windowStart,
cachedAt: now,
totals: totals,
}
}
// GeminiCooldown returns the fallback cooldown duration for Gemini 429s based on tier.
func (s *RateLimitService) GeminiCooldown(ctx context.Context, account *Account) time.Duration {
if account == nil {
return 5 * time.Minute
}
return s.geminiQuotaService.CooldownForTier(ctx, account.GeminiTierID())
}
// handleAuthError 处理认证类错误(401/403),停止账号调度
func (s *RateLimitService) handleAuthError(ctx context.Context, account *Account, errorMsg string) {
if err := s.accountRepo.SetError(ctx, account.ID, errorMsg); err != nil {

View File

@@ -94,6 +94,7 @@ var ProviderSet = wire.NewSet(
NewOAuthService,
NewOpenAIOAuthService,
NewGeminiOAuthService,
NewGeminiQuotaService,
NewAntigravityOAuthService,
NewGeminiTokenProvider,
NewGeminiMessagesCompatService,

View File

@@ -0,0 +1,30 @@
-- +goose Up
-- +goose StatementBegin
-- 为 Gemini Code Assist OAuth 账号添加默认 tier_id
-- 包括显式标记为 code_assist 的账号,以及 legacy 账号oauth_type 为空但 project_id 存在)
UPDATE accounts
SET credentials = jsonb_set(
credentials,
'{tier_id}',
'"LEGACY"',
true
)
WHERE platform = 'gemini'
AND type = 'oauth'
AND jsonb_typeof(credentials) = 'object'
AND credentials->>'tier_id' IS NULL
AND (
credentials->>'oauth_type' = 'code_assist'
OR (credentials->>'oauth_type' IS NULL AND credentials->>'project_id' IS NOT NULL)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- 回滚:删除 tier_id 字段
UPDATE accounts
SET credentials = credentials - 'tier_id'
WHERE platform = 'gemini'
AND type = 'oauth'
AND credentials->>'oauth_type' = 'code_assist';
-- +goose StatementEnd

View File

@@ -0,0 +1,178 @@
# Database Migrations
## Overview
This directory contains SQL migration files for database schema changes. The migration system uses SHA256 checksums to ensure migration immutability and consistency across environments.
## Migration File Naming
Format: `NNN_description.sql`
- `NNN`: Sequential number (e.g., 001, 002, 003)
- `description`: Brief description in snake_case
Example: `017_add_gemini_tier_id.sql`
## Migration File Structure
```sql
-- +goose Up
-- +goose StatementBegin
-- Your forward migration SQL here
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Your rollback migration SQL here
-- +goose StatementEnd
```
## Important Rules
### ⚠️ Immutability Principle
**Once a migration is applied to ANY environment (dev, staging, production), it MUST NOT be modified.**
Why?
- Each migration has a SHA256 checksum stored in the `schema_migrations` table
- Modifying an applied migration causes checksum mismatch errors
- Different environments would have inconsistent database states
- Breaks audit trail and reproducibility
### ✅ Correct Workflow
1. **Create new migration**
```bash
# Create new file with next sequential number
touch migrations/018_your_change.sql
```
2. **Write Up and Down migrations**
- Up: Apply the change
- Down: Revert the change (should be symmetric with Up)
3. **Test locally**
```bash
# Apply migration
make migrate-up
# Test rollback
make migrate-down
```
4. **Commit and deploy**
```bash
git add migrations/018_your_change.sql
git commit -m "feat(db): add your change"
```
### ❌ What NOT to Do
- ❌ Modify an already-applied migration file
- ❌ Delete migration files
- ❌ Change migration file names
- ❌ Reorder migration numbers
### 🔧 If You Accidentally Modified an Applied Migration
**Error message:**
```
migration 017_add_gemini_tier_id.sql checksum mismatch (db=abc123... file=def456...)
```
**Solution:**
```bash
# 1. Find the original version
git log --oneline -- migrations/017_add_gemini_tier_id.sql
# 2. Revert to the commit when it was first applied
git checkout <commit-hash> -- migrations/017_add_gemini_tier_id.sql
# 3. Create a NEW migration for your changes
touch migrations/018_your_new_change.sql
```
## Migration System Details
- **Checksum Algorithm**: SHA256 of trimmed file content
- **Tracking Table**: `schema_migrations` (filename, checksum, applied_at)
- **Runner**: `internal/repository/migrations_runner.go`
- **Auto-run**: Migrations run automatically on service startup
## Best Practices
1. **Keep migrations small and focused**
- One logical change per migration
- Easier to review and rollback
2. **Write reversible migrations**
- Always provide a working Down migration
- Test rollback before committing
3. **Use transactions**
- Wrap DDL statements in transactions when possible
- Ensures atomicity
4. **Add comments**
- Explain WHY the change is needed
- Document any special considerations
5. **Test in development first**
- Apply migration locally
- Verify data integrity
- Test rollback
## Example Migration
```sql
-- +goose Up
-- +goose StatementBegin
-- Add tier_id field to Gemini OAuth accounts for quota tracking
UPDATE accounts
SET credentials = jsonb_set(
credentials,
'{tier_id}',
'"LEGACY"',
true
)
WHERE platform = 'gemini'
AND type = 'oauth'
AND credentials->>'tier_id' IS NULL;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Remove tier_id field
UPDATE accounts
SET credentials = credentials - 'tier_id'
WHERE platform = 'gemini'
AND type = 'oauth'
AND credentials->>'tier_id' = 'LEGACY';
-- +goose StatementEnd
```
## Troubleshooting
### Checksum Mismatch
See "If You Accidentally Modified an Applied Migration" above.
### Migration Failed
```bash
# Check migration status
psql -d sub2api -c "SELECT * FROM schema_migrations ORDER BY applied_at DESC;"
# Manually rollback if needed (use with caution)
# Better to fix the migration and create a new one
```
### Need to Skip a Migration (Emergency Only)
```sql
-- DANGEROUS: Only use in development or with extreme caution
INSERT INTO schema_migrations (filename, checksum, applied_at)
VALUES ('NNN_migration.sql', 'calculated_checksum', NOW());
```
## References
- Migration runner: `internal/repository/migrations_runner.go`
- Goose syntax: https://github.com/pressly/goose
- PostgreSQL docs: https://www.postgresql.org/docs/

View File

@@ -86,3 +86,11 @@ GEMINI_OAUTH_CLIENT_ID=
GEMINI_OAUTH_CLIENT_SECRET=
# Optional; leave empty to auto-select scopes based on oauth_type
GEMINI_OAUTH_SCOPES=
# -----------------------------------------------------------------------------
# Gemini Quota Policy (OPTIONAL, local simulation)
# -----------------------------------------------------------------------------
# JSON overrides for local quota simulation (Code Assist only).
# Example:
# GEMINI_QUOTA_POLICY={"tiers":{"LEGACY":{"pro_rpd":50,"flash_rpd":1500,"cooldown_minutes":30},"PRO":{"pro_rpd":1500,"flash_rpd":4000,"cooldown_minutes":5},"ULTRA":{"pro_rpd":2000,"flash_rpd":0,"cooldown_minutes":5}}}
GEMINI_QUOTA_POLICY=

View File

@@ -123,6 +123,7 @@ docker-compose down -v
| `GEMINI_OAUTH_CLIENT_ID` | No | *(builtin)* | Google OAuth client ID (Gemini OAuth). Leave empty to use the built-in Gemini CLI client. |
| `GEMINI_OAUTH_CLIENT_SECRET` | No | *(builtin)* | Google OAuth client secret (Gemini OAuth). Leave empty to use the built-in Gemini CLI client. |
| `GEMINI_OAUTH_SCOPES` | No | *(default)* | OAuth scopes (Gemini OAuth) |
| `GEMINI_QUOTA_POLICY` | No | *(empty)* | JSON overrides for Gemini local quota simulation (Code Assist only). |
See `.env.example` for all available options.

View File

@@ -156,3 +156,19 @@ gemini:
client_secret: "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"
# Optional scopes (space-separated). Leave empty to auto-select based on oauth_type.
scopes: ""
quota:
# Optional: local quota simulation for Gemini Code Assist (local billing).
# These values are used for UI progress + precheck scheduling, not official Google quotas.
tiers:
LEGACY:
pro_rpd: 50
flash_rpd: 1500
cooldown_minutes: 30
PRO:
pro_rpd: 1500
flash_rpd: 4000
cooldown_minutes: 5
ULTRA:
pro_rpd: 2000
flash_rpd: 0
cooldown_minutes: 5

View File

@@ -90,6 +90,7 @@ services:
- GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-}
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
depends_on:
postgres:
condition: service_healthy

View File

@@ -0,0 +1,21 @@
# =============================================================================
# Docker Compose Override for Local Development
# =============================================================================
# This file automatically extends docker-compose-test.yml
# Usage: docker-compose -f docker-compose-test.yml up -d
# =============================================================================
services:
# ===========================================================================
# PostgreSQL - 暴露端口用于本地开发
# ===========================================================================
postgres:
ports:
- "127.0.0.1:5432:5432"
# ===========================================================================
# Redis - 暴露端口用于本地开发
# ===========================================================================
redis:
ports:
- "127.0.0.1:6379:6379"

View File

@@ -90,6 +90,7 @@ services:
- GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-}
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
depends_on:
postgres:
condition: service_healthy

View File

@@ -0,0 +1,154 @@
<template>
<div v-if="shouldShowQuota" class="flex items-center gap-2">
<!-- Tier Badge -->
<span :class="['badge text-xs px-2 py-0.5 rounded font-medium', tierBadgeClass]">
{{ tierLabel }}
</span>
<!-- 限流状态 -->
<span
v-if="!isRateLimited"
class="text-xs text-gray-400 dark:text-gray-500"
>
{{ t('admin.accounts.gemini.rateLimit.ok') }}
</span>
<span
v-else
:class="[
'text-xs font-medium',
isUrgent
? 'text-red-600 dark:text-red-400 animate-pulse'
: 'text-amber-600 dark:text-amber-400'
]"
>
{{ t('admin.accounts.gemini.rateLimit.limited', { time: resetCountdown }) }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account, GeminiCredentials } from '@/types'
const props = defineProps<{
account: Account
}>()
const { t } = useI18n()
const now = ref(new Date())
let timer: ReturnType<typeof setInterval> | null = null
// 是否为 Code Assist OAuth
// 判断逻辑与后端保持一致project_id 存在即为 Code Assist
const isCodeAssist = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
// 显式为 code_assist或 legacy 情况oauth_type 为空但 project_id 存在)
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
})
// 是否应该显示配额信息
const shouldShowQuota = computed(() => {
return props.account.platform === 'gemini'
})
// Tier 标签文本
const tierLabel = computed(() => {
if (isCodeAssist.value) {
const creds = props.account.credentials as GeminiCredentials | undefined
const tierMap: Record<string, string> = {
LEGACY: 'Free',
PRO: 'Pro',
ULTRA: 'Ultra'
}
return tierMap[creds?.tier_id || ''] || 'Unknown'
}
return 'Gemini'
})
// Tier Badge 样式
const tierBadgeClass = computed(() => {
if (!isCodeAssist.value) {
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
}
const creds = props.account.credentials as GeminiCredentials | undefined
const tierColorMap: Record<string, string> = {
LEGACY: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
PRO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
ULTRA: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}
return (
tierColorMap[creds?.tier_id || ''] ||
'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
)
})
// 是否限流
const isRateLimited = computed(() => {
if (!props.account.rate_limit_reset_at) return false
const resetTime = Date.parse(props.account.rate_limit_reset_at)
// 防护如果日期解析失败NaN则认为未限流
if (Number.isNaN(resetTime)) return false
return resetTime > now.value.getTime()
})
// 倒计时文本
const resetCountdown = computed(() => {
if (!props.account.rate_limit_reset_at) return ''
const resetTime = Date.parse(props.account.rate_limit_reset_at)
// 防护:如果日期解析失败,显示 "-"
if (Number.isNaN(resetTime)) return '-'
const diffMs = resetTime - now.value.getTime()
if (diffMs <= 0) return t('admin.accounts.gemini.rateLimit.now')
const diffSeconds = Math.floor(diffMs / 1000)
const diffMinutes = Math.floor(diffSeconds / 60)
const diffHours = Math.floor(diffMinutes / 60)
if (diffMinutes < 1) return `${diffSeconds}s`
if (diffHours < 1) {
const secs = diffSeconds % 60
return `${diffMinutes}m ${secs}s`
}
const mins = diffMinutes % 60
return `${diffHours}h ${mins}m`
})
// 是否紧急(< 1分钟
const isUrgent = computed(() => {
if (!props.account.rate_limit_reset_at) return false
const resetTime = Date.parse(props.account.rate_limit_reset_at)
// 防护:如果日期解析失败,返回 false
if (Number.isNaN(resetTime)) return false
const diffMs = resetTime - now.value.getTime()
return diffMs > 0 && diffMs < 60000
})
// 监听限流状态,动态启动/停止定时器
watch(
() => isRateLimited.value,
(limited) => {
if (limited && !timer) {
// 进入限流状态,启动定时器
timer = setInterval(() => {
now.value = new Date()
}, 1000)
} else if (!limited && timer) {
// 解除限流,停止定时器
clearInterval(timer)
timer = null
}
},
{ immediate: true } // 立即执行,确保挂载时已限流的情况也能启动定时器
)
onUnmounted(() => {
if (timer !== null) {
clearInterval(timer)
timer = null
}
})
</script>

View File

@@ -169,6 +169,88 @@
<div v-else class="text-xs text-gray-400">-</div>
</template>
<!-- Gemini platform: show quota + local usage window -->
<template v-else-if="account.platform === 'gemini'">
<!-- 账户类型徽章 -->
<div v-if="geminiTierLabel" class="mb-1 flex items-center gap-1">
<span
:class="[
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
geminiTierClass
]"
>
{{ geminiTierLabel }}
</span>
<!-- 帮助图标 -->
<span
class="group relative cursor-help"
>
<svg
class="h-3.5 w-3.5 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>
<span
class="pointer-events-none absolute left-0 top-full z-50 mt-1 w-80 whitespace-normal break-words rounded bg-gray-900 px-3 py-2 text-xs leading-relaxed text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
<div class="font-semibold mb-1">{{ t('admin.accounts.gemini.quotaPolicy.title') }}</div>
<div class="mb-2 text-gray-300">{{ t('admin.accounts.gemini.quotaPolicy.note') }}</div>
<div class="space-y-1">
<div><strong>{{ geminiQuotaPolicyChannel }}:</strong></div>
<div class="pl-2">• {{ geminiQuotaPolicyLimits }}</div>
<div class="mt-2">
<a :href="geminiQuotaPolicyDocsUrl" target="_blank" class="text-blue-400 hover:text-blue-300 underline">
{{ t('admin.accounts.gemini.quotaPolicy.columns.docs') }} →
</a>
</div>
</div>
</span>
</span>
</div>
<div class="space-y-1">
<div v-if="loading" class="space-y-1">
<div class="flex items-center gap-1">
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
<div v-else-if="error" class="text-xs text-red-500">
{{ error }}
</div>
<div v-else-if="geminiUsageAvailable" class="space-y-1">
<UsageProgressBar
v-if="usageInfo?.gemini_pro_daily"
:label="t('admin.accounts.usageWindow.geminiProDaily')"
:utilization="usageInfo.gemini_pro_daily.utilization"
:resets-at="usageInfo.gemini_pro_daily.resets_at"
:window-stats="usageInfo.gemini_pro_daily.window_stats"
:stats-title="t('admin.accounts.usageWindow.statsTitleDaily')"
color="indigo"
/>
<UsageProgressBar
v-if="usageInfo?.gemini_flash_daily"
:label="t('admin.accounts.usageWindow.geminiFlashDaily')"
:utilization="usageInfo.gemini_flash_daily.utilization"
:resets-at="usageInfo.gemini_flash_daily.resets_at"
:window-stats="usageInfo.gemini_flash_daily.window_stats"
:stats-title="t('admin.accounts.usageWindow.statsTitleDaily')"
color="emerald"
/>
<p class="mt-1 text-[9px] leading-tight text-gray-400 dark:text-gray-500 italic">
* {{ t('admin.accounts.gemini.quotaPolicy.simulatedNote') || 'Simulated quota' }}
</p>
</div>
</div>
</template>
<!-- Other accounts: no usage window -->
<template v-else>
<div class="text-xs text-gray-400">-</div>
@@ -176,15 +258,20 @@
</div>
<!-- Non-OAuth/Setup-Token accounts -->
<div v-else class="text-xs text-gray-400">-</div>
<div v-else>
<!-- Gemini API Key accounts: show quota info -->
<AccountQuotaInfo v-if="account.platform === 'gemini'" :account="account" />
<div v-else class="text-xs text-gray-400">-</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo } from '@/types'
import type { Account, AccountUsageInfo, GeminiCredentials } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
const props = defineProps<{
account: Account
@@ -201,6 +288,23 @@ const showUsageWindows = computed(
() => props.account.type === 'oauth' || props.account.type === 'setup-token'
)
const shouldFetchUsage = computed(() => {
if (props.account.platform === 'anthropic') {
return props.account.type === 'oauth' || props.account.type === 'setup-token'
}
if (props.account.platform === 'gemini') {
return props.account.type === 'oauth'
}
return false
})
const geminiUsageAvailable = computed(() => {
return (
!!usageInfo.value?.gemini_pro_daily ||
!!usageInfo.value?.gemini_flash_daily
)
})
// OpenAI Codex usage computed properties
const hasCodexUsage = computed(() => {
const extra = props.account.extra
@@ -447,6 +551,71 @@ const antigravityTier = computed(() => {
return null
})
// Gemini 账户类型(从 credentials 中提取)
const geminiTier = computed(() => {
if (props.account.platform !== 'gemini') return null
const creds = props.account.credentials as GeminiCredentials | undefined
return creds?.tier_id || null
})
// Gemini 是否为 Code Assist OAuth
const isGeminiCodeAssist = computed(() => {
if (props.account.platform !== 'gemini') return false
const creds = props.account.credentials as GeminiCredentials | undefined
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
})
// Gemini 账户类型显示标签
const geminiTierLabel = computed(() => {
if (!geminiTier.value) return null
const tierMap: Record<string, string> = {
LEGACY: t('admin.accounts.tier.free'),
PRO: t('admin.accounts.tier.pro'),
ULTRA: t('admin.accounts.tier.ultra')
}
return tierMap[geminiTier.value] || null
})
// Gemini 账户类型徽章样式
const geminiTierClass = computed(() => {
switch (geminiTier.value) {
case 'LEGACY':
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
case 'PRO':
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
case 'ULTRA':
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
default:
return ''
}
})
// Gemini 配额政策信息
const geminiQuotaPolicyChannel = computed(() => {
if (isGeminiCodeAssist.value) {
return t('admin.accounts.gemini.quotaPolicy.rows.cli.channel')
}
return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.channel')
})
const geminiQuotaPolicyLimits = computed(() => {
if (isGeminiCodeAssist.value) {
if (geminiTier.value === 'PRO' || geminiTier.value === 'ULTRA') {
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsPremium')
}
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsFree')
}
// AI Studio - 默认显示免费层限制
return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsFree')
})
const geminiQuotaPolicyDocsUrl = computed(() => {
if (isGeminiCodeAssist.value) {
return 'https://cloud.google.com/products/gemini/code-assist#pricing'
}
return 'https://ai.google.dev/pricing'
})
// 账户类型显示标签
const antigravityTierLabel = computed(() => {
switch (antigravityTier.value) {
@@ -488,10 +657,7 @@ const hasIneligibleTiers = computed(() => {
})
const loadUsage = async () => {
// Fetch usage for Anthropic OAuth and Setup Token accounts
// OpenAI usage comes from account.extra field (updated during forwarding)
if (props.account.platform !== 'anthropic') return
if (props.account.type !== 'oauth' && props.account.type !== 'setup-token') return
if (!shouldFetchUsage.value) return
loading.value = true
error.value = null

View File

@@ -373,8 +373,12 @@
</svg>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.googleOauth') }}</span>
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.accountType.oauthTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.accountType.oauthDesc') }}
</span>
</div>
</button>
@@ -411,12 +415,42 @@
</svg>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
<span class="text-xs text-gray-500 dark:text-gray-400">AI Studio API Key</span>
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.accountType.apiKeyTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.accountType.apiKeyDesc') }}
</span>
</div>
</button>
</div>
<div
v-if="accountCategory === 'apikey'"
class="mt-3 rounded-lg border border-purple-200 bg-purple-50 px-3 py-2 text-xs text-purple-800 dark:border-purple-800/40 dark:bg-purple-900/20 dark:text-purple-200"
>
<p>{{ t('admin.accounts.gemini.accountType.apiKeyNote') }}</p>
<div class="mt-2 flex flex-wrap gap-2">
<a
:href="geminiHelpLinks.apiKey"
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.accountType.apiKeyLink') }}
</a>
<span class="text-purple-400">·</span>
<a
:href="geminiHelpLinks.aiStudioPricing"
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.accountType.quotaLink') }}
</a>
</div>
</div>
<!-- OAuth Type Selection (only show when oauth-based is selected) -->
<div v-if="accountCategory === 'oauth-based'" class="mt-4">
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label>
@@ -443,10 +477,41 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
</svg>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.types.codeAssist') }}</span>
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ t('admin.accounts.oauth.gemini.needsProjectId') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauth.gemini.needsProjectIdDesc') }}</span>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.builtInTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.builtInDesc') }}
</span>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.builtInRequirement') }}
<a
:href="geminiHelpLinks.gcpProject"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.oauthType.gcpProjectLink') }}
</a>
</div>
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.recommended') }}
</span>
<span
class="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.highConcurrency') }}
</span>
<span
class="rounded bg-gray-100 px-2 py-0.5 text-[10px] font-semibold text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.noAdmin') }}
</span>
</div>
</div>
</button>
@@ -486,13 +551,27 @@
</svg>
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">AI Studio</span>
<span class="block text-xs font-medium text-purple-600 dark:text-purple-400">{{
t('admin.accounts.oauth.gemini.noProjectIdNeeded')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.oauth.gemini.noProjectIdNeededDesc')
}}</span>
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.customTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customDesc') }}
</span>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customRequirement') }}
</div>
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded bg-purple-100 px-2 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.orgManaged') }}
</span>
<span
class="rounded bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.adminRequired') }}
</span>
</div>
</div>
<span
v-if="!geminiAIStudioOAuthEnabled"
@@ -511,6 +590,79 @@
</div>
</div>
</div>
<div class="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-4 text-xs text-blue-900 dark:border-blue-800/40 dark:bg-blue-900/20 dark:text-blue-200">
<div class="flex items-start gap-3">
<svg
class="h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.gemini.setupGuide.title') }}
</p>
<div class="mt-2 space-y-2">
<div>
<p class="font-semibold text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.gemini.setupGuide.checklistTitle') }}
</p>
<ul class="mt-1 list-disc space-y-1 pl-4">
<li>
{{ t('admin.accounts.gemini.setupGuide.checklistItems.usIp') }}
<a
:href="geminiHelpLinks.countryCheck"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.setupGuide.links.countryCheck') }}
</a>
</li>
<li>{{ t('admin.accounts.gemini.setupGuide.checklistItems.age') }}</li>
</ul>
</div>
<div>
<p class="font-semibold text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.gemini.setupGuide.activationTitle') }}
</p>
<ul class="mt-1 list-disc space-y-1 pl-4">
<li>
{{ t('admin.accounts.gemini.setupGuide.activationItems.geminiWeb') }}
<a
:href="geminiHelpLinks.geminiWebActivation"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.setupGuide.links.geminiWebActivation') }}
</a>
</li>
<li>
{{ t('admin.accounts.gemini.setupGuide.activationItems.gcpProject') }}
<a
:href="geminiHelpLinks.gcpProject"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.setupGuide.links.gcpProject') }}
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Account Type Selection (Antigravity - OAuth only) -->
@@ -969,6 +1121,165 @@
</div>
</div>
</div>
<!-- Gemini 配额与限流政策说明 -->
<div v-if="form.platform === 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800/40">
<div class="flex items-start gap-3">
<svg
class="h-5 w-5 flex-shrink-0 text-gray-500 dark:text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800 dark:text-gray-200">
{{ t('admin.accounts.gemini.quotaPolicy.title') }}
</p>
<p class="mt-1 text-xs text-gray-600 dark:text-gray-400">
{{ t('admin.accounts.gemini.quotaPolicy.note') }}
</p>
<div class="mt-3 overflow-x-auto">
<table class="min-w-full text-xs text-gray-700 dark:text-gray-300">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="px-2 py-1.5 text-left font-semibold">
{{ t('admin.accounts.gemini.quotaPolicy.columns.channel') }}
</th>
<th class="px-2 py-1.5 text-left font-semibold">
{{ t('admin.accounts.gemini.quotaPolicy.columns.account') }}
</th>
<th class="px-2 py-1.5 text-left font-semibold">
{{ t('admin.accounts.gemini.quotaPolicy.columns.limits') }}
</th>
<th class="px-2 py-1.5 text-left font-semibold">
{{ t('admin.accounts.gemini.quotaPolicy.columns.docs') }}
</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-1.5 align-top" rowspan="2">
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.channel') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.free') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsFree') }}
</td>
<td class="px-2 py-1.5 align-top" rowspan="2">
<a
:href="geminiQuotaDocs.codeAssist"
class="text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.quotaPolicy.docs.codeAssist') }}
</a>
</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.premium') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsPremium') }}
</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-1.5 align-top">
{{ t('admin.accounts.gemini.quotaPolicy.rows.gcloud.channel') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.gcloud.account') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.gcloud.limits') }}
</td>
<td class="px-2 py-1.5 align-top">
<a
:href="geminiQuotaDocs.codeAssist"
class="text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.quotaPolicy.docs.codeAssist') }}
</a>
</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-1.5 align-top" rowspan="2">
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.channel') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.free') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsFree') }}
</td>
<td class="px-2 py-1.5 align-top" rowspan="2">
<a
:href="geminiQuotaDocs.aiStudio"
class="text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.quotaPolicy.docs.aiStudio') }}
</a>
</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.paid') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsPaid') }}
</td>
</tr>
<tr>
<td class="px-2 py-1.5 align-top" rowspan="2">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.channel') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.free') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.limitsFree') }}
</td>
<td class="px-2 py-1.5 align-top" rowspan="2">
<a
:href="geminiQuotaDocs.vertex"
class="text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.quotaPolicy.docs.vertex') }}
</a>
</td>
</tr>
<tr>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.paid') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.limitsPaid') }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Intercept Warmup Requests (Anthropic only) -->
@@ -1333,6 +1644,20 @@ const geminiModels = [
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash' }
]
const geminiQuotaDocs = {
codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas',
aiStudio: 'https://ai.google.dev/pricing',
vertex: 'https://cloud.google.com/vertex-ai/generative-ai/docs/quotas'
}
const geminiHelpLinks = {
apiKey: 'https://aistudio.google.com/app/apikey',
aiStudioPricing: 'https://ai.google.dev/pricing',
gcpProject: 'https://console.cloud.google.com/welcome/new',
geminiWebActivation: 'https://gemini.google.com/gems/create?hl=en-US',
countryCheck: 'https://policies.google.com/country-association-form'
}
// Computed: current models based on platform
const commonModels = computed(() => {
if (form.platform === 'openai') return openaiModels

View File

@@ -121,16 +121,13 @@
/>
</svg>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{
t('admin.accounts.types.codeAssist')
}}</span>
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{
t('admin.accounts.oauth.gemini.needsProjectId')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.oauth.gemini.needsProjectIdDesc')
}}</span>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.builtInTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.builtInDesc') }}
</span>
</div>
</button>
@@ -168,14 +165,13 @@
/>
</svg>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">AI Studio</span>
<span class="block text-xs font-medium text-purple-600 dark:text-purple-400">{{
t('admin.accounts.oauth.gemini.noProjectIdNeeded')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.oauth.gemini.noProjectIdNeededDesc')
}}</span>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.customTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customDesc') }}
</span>
<div v-if="!geminiAIStudioOAuthEnabled" class="group relative mt-1 inline-block">
<span
class="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"

View File

@@ -4,7 +4,7 @@
<div
v-if="windowStats"
class="mb-0.5 flex items-center justify-between"
:title="t('admin.accounts.usageWindow.statsTitle')"
:title="statsTitle || t('admin.accounts.usageWindow.statsTitle')"
>
<div
class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
@@ -60,6 +60,7 @@ const props = defineProps<{
resetsAt?: string | null
color: 'indigo' | 'emerald' | 'purple' | 'amber'
windowStats?: WindowStats | null
statsTitle?: string
}>()
const { t } = useI18n()

View File

@@ -1082,10 +1082,10 @@ export default {
stateWarningTitle: 'Note',
stateWarningDesc: 'Recommended: paste the full callback URL (includes code & state).',
oauthTypeLabel: 'OAuth Type',
needsProjectId: 'For GCP Developers',
needsProjectIdDesc: 'Requires GCP project',
noProjectIdNeeded: 'For Regular Users',
noProjectIdNeededDesc: 'Requires admin-configured OAuth client',
needsProjectId: 'Built-in OAuth (Code Assist)',
needsProjectIdDesc: 'Requires GCP project and Project ID',
noProjectIdNeeded: 'Custom OAuth (AI Studio)',
noProjectIdNeededDesc: 'Requires admin-configured OAuth client',
aiStudioNotConfiguredShort: 'Not configured',
aiStudioNotConfiguredTip:
'AI Studio OAuth is not configured: set GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and add Redirect URI: http://localhost:1455/auth/callback (Consent screen scopes must include https://www.googleapis.com/auth/generative-language.retriever)',
@@ -1120,7 +1120,100 @@ export default {
modelPassthroughDesc:
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
baseUrlHint: 'Leave default for official Gemini API',
apiKeyHint: 'Your Gemini API Key (starts with AIza)'
apiKeyHint: 'Your Gemini API Key (starts with AIza)',
accountType: {
oauthTitle: 'OAuth (Gemini)',
oauthDesc: 'Authorize with your Google account and choose an OAuth type.',
apiKeyTitle: 'API Key (AI Studio)',
apiKeyDesc: 'Fastest setup. Use an AIza API key.',
apiKeyNote:
'Best for light testing. Free tier has strict rate limits and data may be used for training.',
apiKeyLink: 'Get API Key',
quotaLink: 'Quota guide'
},
oauthType: {
builtInTitle: 'Built-in OAuth (Gemini CLI / Code Assist)',
builtInDesc: 'Uses Google built-in client ID. No admin configuration required.',
builtInRequirement: 'Requires a GCP project and Project ID.',
gcpProjectLink: 'Create project',
customTitle: 'Custom OAuth (AI Studio OAuth)',
customDesc: 'Uses admin-configured OAuth client for org management.',
customRequirement: 'Admin must configure Client ID and add you as a test user.',
badges: {
recommended: 'Recommended',
highConcurrency: 'High concurrency',
noAdmin: 'No admin setup',
orgManaged: 'Org managed',
adminRequired: 'Admin required'
}
},
setupGuide: {
title: 'Gemini Setup Checklist',
checklistTitle: 'Checklist',
checklistItems: {
usIp: 'Use a US IP and ensure your account country is set to US.',
age: 'Account must be 18+.'
},
activationTitle: 'One-click Activation',
activationItems: {
geminiWeb: 'Activate Gemini Web to avoid User not initialized.',
gcpProject: 'Activate a GCP project and get the Project ID for Code Assist.'
},
links: {
countryCheck: 'Check country association',
geminiWebActivation: 'Activate Gemini Web',
gcpProject: 'Open GCP Console'
}
},
quotaPolicy: {
title: 'Gemini Quota & Limit Policy (Reference)',
note: 'Note: Gemini does not provide an official quota inquiry API. The "Daily Quota" shown here is an estimate simulated by the system based on account tiers for scheduling reference only. Please refer to official Google errors for actual limits.',
columns: {
channel: 'Auth Channel',
account: 'Account Status',
limits: 'Limit Policy',
docs: 'Official Docs'
},
docs: {
codeAssist: 'Code Assist Quotas',
aiStudio: 'AI Studio Pricing',
vertex: 'Vertex AI Quotas'
},
simulatedNote: 'Simulated quota, for reference only',
rows: {
cli: {
channel: 'Gemini CLI (Official Google Login / Code Assist)',
free: 'Free Google Account',
premium: 'Google One AI Premium',
limitsFree: 'RPD ~1000; RPM ~60 (soft)',
limitsPremium: 'RPD ~1500+; RPM ~60+ (priority queue)'
},
gcloud: {
channel: 'GCP Code Assist (gcloud auth)',
account: 'No Code Assist subscription',
limits: 'RPD ~1000; RPM ~60 (preview)'
},
aiStudio: {
channel: 'AI Studio API Key / OAuth',
free: 'No billing (free tier)',
paid: 'Billing enabled (pay-as-you-go)',
limitsFree: 'RPD 50; RPM 2 (Pro) / 15 (Flash)',
limitsPaid: 'RPD unlimited; RPM 1000+ (per model quota)'
},
customOAuth: {
channel: 'Custom OAuth Client (GCP)',
free: 'Project not billed',
paid: 'Project billed',
limitsFree: 'RPD 50; RPM 2 (project quota)',
limitsPaid: 'RPD unlimited; RPM 1000+ (project quota)'
}
}
},
rateLimit: {
ok: 'Not rate limited',
limited: 'Rate limited {time}',
now: 'now'
}
},
// Re-Auth Modal
reAuthorizeAccount: 'Re-Authorize Account',
@@ -1186,6 +1279,9 @@ export default {
},
usageWindow: {
statsTitle: '5-Hour Window Usage Statistics',
statsTitleDaily: 'Daily Usage Statistics',
geminiProDaily: 'Pro',
geminiFlashDaily: 'Flash',
gemini3Pro: 'G3P',
gemini3Flash: 'G3F',
gemini3Image: 'G3I',

View File

@@ -985,6 +985,9 @@ export default {
},
usageWindow: {
statsTitle: '5小时窗口用量统计',
statsTitleDaily: '每日用量统计',
geminiProDaily: 'Pro',
geminiFlashDaily: 'Flash',
gemini3Pro: 'G3P',
gemini3Flash: 'G3F',
gemini3Image: 'G3I',
@@ -1217,10 +1220,10 @@ export default {
stateWarningTitle: '提示',
stateWarningDesc: '建议粘贴完整回调链接(包含 code 和 state。',
oauthTypeLabel: 'OAuth 类型',
needsProjectId: '适合 GCP 开发者',
needsProjectIdDesc: '需 GCP 项目',
noProjectIdNeeded: '适合普通用户',
noProjectIdNeededDesc: '需管理员配置 OAuth Client',
needsProjectId: '内置授权Code Assist',
needsProjectIdDesc: '需 GCP 项目与 Project ID',
noProjectIdNeeded: '自定义授权AI Studio',
noProjectIdNeededDesc: '需管理员配置 OAuth Client',
aiStudioNotConfiguredShort: '未配置',
aiStudioNotConfiguredTip: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET并在 Google OAuth Client 添加 Redirect URIhttp://localhost:1455/auth/callbackConsent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever',
aiStudioNotConfigured: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET并在 Google OAuth Client 添加 Redirect URIhttp://localhost:1455/auth/callback'
@@ -1252,7 +1255,99 @@ export default {
modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API不进行模型限制或映射。',
baseUrlHint: '留空使用官方 Gemini API',
apiKeyHint: '您的 Gemini API Key以 AIza 开头)'
apiKeyHint: '您的 Gemini API Key以 AIza 开头)',
accountType: {
oauthTitle: 'OAuth 授权Gemini',
oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。',
apiKeyTitle: 'API 密钥AI Studio',
apiKeyDesc: '最快接入方式,使用 AIza API Key。',
apiKeyNote: '适合轻量测试。免费层限流严格,数据可能用于训练。',
apiKeyLink: '获取 API Key',
quotaLink: '配额说明'
},
oauthType: {
builtInTitle: '内置授权Gemini CLI / Code Assist',
builtInDesc: '使用 Google 内置客户端 ID无需管理员配置。',
builtInRequirement: '需要 GCP 项目并填写 Project ID。',
gcpProjectLink: '创建项目',
customTitle: '自定义授权AI Studio OAuth',
customDesc: '使用管理员预设的 OAuth 客户端,适合组织管理。',
customRequirement: '需管理员配置 Client ID 并加入测试用户白名单。',
badges: {
recommended: '推荐',
highConcurrency: '高并发',
noAdmin: '无需管理员配置',
orgManaged: '组织管理',
adminRequired: '需要管理员'
}
},
setupGuide: {
title: 'Gemini 使用准备',
checklistTitle: '准备工作',
checklistItems: {
usIp: '使用美国 IP并确保账号归属地为美国。',
age: '账号需满 18 岁。'
},
activationTitle: '服务激活',
activationItems: {
geminiWeb: '激活 Gemini Web避免 User not initialized。',
gcpProject: '激活 GCP 项目,获取 Code Assist 所需 Project ID。'
},
links: {
countryCheck: '检查归属地',
geminiWebActivation: '激活 Gemini Web',
gcpProject: '打开 GCP 控制台'
}
},
quotaPolicy: {
title: 'Gemini 配额与限流政策(参考)',
note: '注意Gemini 官方未提供用量查询接口。此处显示的“每日配额”是由系统根据账号等级模拟计算的估算值,仅供调度参考,请以 Google 官方实际报错为准。',
columns: {
channel: '授权通道',
account: '账号状态',
limits: '限流政策',
docs: '官方文档'
},
docs: {
codeAssist: 'Code Assist 配额',
aiStudio: 'AI Studio 定价',
vertex: 'Vertex AI 配额'
},
simulatedNote: '本地模拟配额,仅供参考',
rows: {
cli: {
channel: 'Gemini CLI官方 Google 登录 / Code Assist',
free: '免费 Google 账号',
premium: 'Google One AI Premium',
limitsFree: 'RPD ~1000RPM ~60软限制',
limitsPremium: 'RPD ~1500+RPM ~60+(优先队列)'
},
gcloud: {
channel: 'GCP Code Assistgcloud 登录)',
account: '未购买 Code Assist 订阅',
limits: 'RPD ~1000RPM ~60预览期'
},
aiStudio: {
channel: 'AI Studio API Key / OAuth',
free: '未绑卡(免费层)',
paid: '已绑卡(按量付费)',
limitsFree: 'RPD 50RPM 2Pro/ 15Flash',
limitsPaid: 'RPD 不限RPM 1000+(按模型配额)'
},
customOAuth: {
channel: 'Custom OAuth ClientGCP',
free: '项目未绑卡',
paid: '项目已绑卡',
limitsFree: 'RPD 50RPM 2项目配额',
limitsPaid: 'RPD 不限RPM 1000+(项目配额)'
}
}
},
rateLimit: {
ok: '未限流',
limited: '限流 {time}',
now: '现在'
}
},
// Re-Auth Modal
reAuthorizeAccount: '重新授权账号',

View File

@@ -315,6 +315,22 @@ export interface Proxy {
updated_at: string
}
// Gemini credentials structure for OAuth and API Key authentication
export interface GeminiCredentials {
// API Key authentication
api_key?: string
// OAuth authentication
access_token?: string
refresh_token?: string
oauth_type?: 'code_assist' | 'ai_studio' | string
tier_id?: 'LEGACY' | 'PRO' | 'ULTRA' | string
project_id?: string
token_type?: string
scope?: string
expires_at?: string
}
export interface Account {
id: number
name: string
@@ -366,6 +382,8 @@ export interface AccountUsageInfo {
five_hour: UsageProgress | null
seven_day: UsageProgress | null
seven_day_sonnet: UsageProgress | null
gemini_pro_daily?: UsageProgress | null
gemini_flash_daily?: UsageProgress | null
}
// OpenAI Codex usage snapshot (from response headers)