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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -77,6 +77,7 @@ temp/
|
||||
*.temp
|
||||
*.log
|
||||
*.bak
|
||||
.cache/
|
||||
|
||||
# ===================
|
||||
# 构建产物
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 // 迁移已应用且校验和匹配,跳过
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
268
backend/internal/service/gemini_quota.go
Normal file
268
backend/internal/service/gemini_quota.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -94,6 +94,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewOAuthService,
|
||||
NewOpenAIOAuthService,
|
||||
NewGeminiOAuthService,
|
||||
NewGeminiQuotaService,
|
||||
NewAntigravityOAuthService,
|
||||
NewGeminiTokenProvider,
|
||||
NewGeminiMessagesCompatService,
|
||||
|
||||
30
backend/migrations/017_add_gemini_tier_id.sql
Normal file
30
backend/migrations/017_add_gemini_tier_id.sql
Normal 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
|
||||
178
backend/migrations/README.md
Normal file
178
backend/migrations/README.md
Normal 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/
|
||||
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
21
deploy/docker-compose.override.yml
Normal file
21
deploy/docker-compose.override.yml
Normal 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"
|
||||
@@ -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
|
||||
|
||||
154
frontend/src/components/account/AccountQuotaInfo.vue
Normal file
154
frontend/src/components/account/AccountQuotaInfo.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 URI:http://localhost:1455/auth/callback(Consent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever)',
|
||||
aiStudioNotConfigured: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback'
|
||||
@@ -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 ~1000;RPM ~60(软限制)',
|
||||
limitsPremium: 'RPD ~1500+;RPM ~60+(优先队列)'
|
||||
},
|
||||
gcloud: {
|
||||
channel: 'GCP Code Assist(gcloud 登录)',
|
||||
account: '未购买 Code Assist 订阅',
|
||||
limits: 'RPD ~1000;RPM ~60(预览期)'
|
||||
},
|
||||
aiStudio: {
|
||||
channel: 'AI Studio API Key / OAuth',
|
||||
free: '未绑卡(免费层)',
|
||||
paid: '已绑卡(按量付费)',
|
||||
limitsFree: 'RPD 50;RPM 2(Pro)/ 15(Flash)',
|
||||
limitsPaid: 'RPD 不限;RPM 1000+(按模型配额)'
|
||||
},
|
||||
customOAuth: {
|
||||
channel: 'Custom OAuth Client(GCP)',
|
||||
free: '项目未绑卡',
|
||||
paid: '项目已绑卡',
|
||||
limitsFree: 'RPD 50;RPM 2(项目配额)',
|
||||
limitsPaid: 'RPD 不限;RPM 1000+(项目配额)'
|
||||
}
|
||||
}
|
||||
},
|
||||
rateLimit: {
|
||||
ok: '未限流',
|
||||
limited: '限流 {time}',
|
||||
now: '现在'
|
||||
}
|
||||
},
|
||||
// Re-Auth Modal
|
||||
reAuthorizeAccount: '重新授权账号',
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user