Commit a185ad11 authored by IanShaw027's avatar IanShaw027
Browse files

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

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

测试:所有单元测试通过
parent cc4cc806
# Changelog
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project aims to follow Semantic Versioning.
## [Unreleased]
### Breaking Changes
- Admin ops error logs: `GET /api/v1/admin/ops/error-logs` now enforces `limit <= 500` (previously `<= 5000`). Requests with `limit > 500` return `400 Bad Request` (`Invalid limit (must be 1-500)`).
### Migration
- Prefer the paginated endpoint `GET /api/v1/admin/ops/errors` using `page` / `page_size`.
- If you must keep using `.../error-logs`, reduce `limit` to `<= 500` and fetch multiple pages by splitting queries (e.g., by time window) instead of requesting a single large result set.
...@@ -30,6 +30,8 @@ type GeminiGenerateAuthURLRequest struct { ...@@ -30,6 +30,8 @@ type GeminiGenerateAuthURLRequest struct {
// OAuth 类型: "code_assist" (需要 project_id) 或 "ai_studio" (不需要 project_id) // OAuth 类型: "code_assist" (需要 project_id) 或 "ai_studio" (不需要 project_id)
// 默认为 "code_assist" 以保持向后兼容 // 默认为 "code_assist" 以保持向后兼容
OAuthType string `json:"oauth_type"` OAuthType string `json:"oauth_type"`
// TierID is a user-selected tier to be used when auto detection is unavailable or fails.
TierID string `json:"tier_id"`
} }
// GenerateAuthURL generates Google OAuth authorization URL for Gemini. // GenerateAuthURL generates Google OAuth authorization URL for Gemini.
...@@ -54,7 +56,7 @@ func (h *GeminiOAuthHandler) GenerateAuthURL(c *gin.Context) { ...@@ -54,7 +56,7 @@ func (h *GeminiOAuthHandler) GenerateAuthURL(c *gin.Context) {
// Always pass the "hosted" callback URI; the OAuth service may override it depending on // Always pass the "hosted" callback URI; the OAuth service may override it depending on
// oauth_type and whether the built-in Gemini CLI OAuth client is used. // oauth_type and whether the built-in Gemini CLI OAuth client is used.
redirectURI := deriveGeminiRedirectURI(c) redirectURI := deriveGeminiRedirectURI(c)
result, err := h.geminiOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID, redirectURI, req.ProjectID, oauthType) result, err := h.geminiOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID, redirectURI, req.ProjectID, oauthType, req.TierID)
if err != nil { if err != nil {
msg := err.Error() msg := err.Error()
// Treat missing/invalid OAuth client configuration as a user/config error. // Treat missing/invalid OAuth client configuration as a user/config error.
...@@ -76,6 +78,9 @@ type GeminiExchangeCodeRequest struct { ...@@ -76,6 +78,9 @@ type GeminiExchangeCodeRequest struct {
ProxyID *int64 `json:"proxy_id"` ProxyID *int64 `json:"proxy_id"`
// OAuth 类型: "code_assist" 或 "ai_studio",需要与 GenerateAuthURL 时的类型一致 // OAuth 类型: "code_assist" 或 "ai_studio",需要与 GenerateAuthURL 时的类型一致
OAuthType string `json:"oauth_type"` OAuthType string `json:"oauth_type"`
// TierID is a user-selected tier to be used when auto detection is unavailable or fails.
// This field is optional; when omitted, the server uses the tier stored in the OAuth session.
TierID string `json:"tier_id"`
} }
// ExchangeCode exchanges authorization code for tokens. // ExchangeCode exchanges authorization code for tokens.
...@@ -103,6 +108,7 @@ func (h *GeminiOAuthHandler) ExchangeCode(c *gin.Context) { ...@@ -103,6 +108,7 @@ func (h *GeminiOAuthHandler) ExchangeCode(c *gin.Context) {
Code: req.Code, Code: req.Code,
ProxyID: req.ProxyID, ProxyID: req.ProxyID,
OAuthType: oauthType, OAuthType: oauthType,
TierID: req.TierID,
}) })
if err != nil { if err != nil {
response.BadRequest(c, "Failed to exchange code: "+err.Error()) response.BadRequest(c, "Failed to exchange code: "+err.Error())
......
...@@ -19,13 +19,17 @@ type OAuthConfig struct { ...@@ -19,13 +19,17 @@ type OAuthConfig struct {
} }
type OAuthSession struct { type OAuthSession struct {
State string `json:"state"` State string `json:"state"`
CodeVerifier string `json:"code_verifier"` CodeVerifier string `json:"code_verifier"`
ProxyURL string `json:"proxy_url,omitempty"` ProxyURL string `json:"proxy_url,omitempty"`
RedirectURI string `json:"redirect_uri"` RedirectURI string `json:"redirect_uri"`
ProjectID string `json:"project_id,omitempty"` ProjectID string `json:"project_id,omitempty"`
OAuthType string `json:"oauth_type"` // "code_assist" 或 "ai_studio" // TierID is a user-selected fallback tier.
CreatedAt time.Time `json:"created_at"` // For oauth types that support auto detection (google_one/code_assist), the server will prefer
// the detected tier and fall back to TierID when detection fails.
TierID string `json:"tier_id,omitempty"`
OAuthType string `json:"oauth_type"` // "code_assist" 或 "ai_studio"
CreatedAt time.Time `json:"created_at"`
} }
type SessionStore struct { type SessionStore struct {
......
...@@ -30,14 +30,14 @@ func (c *geminiOAuthClient) ExchangeCode(ctx context.Context, oauthType, code, c ...@@ -30,14 +30,14 @@ func (c *geminiOAuthClient) ExchangeCode(ctx context.Context, oauthType, code, c
// Use different OAuth clients based on oauthType: // Use different OAuth clients based on oauthType:
// - code_assist: always use built-in Gemini CLI OAuth client (public) // - code_assist: always use built-in Gemini CLI OAuth client (public)
// - google_one: same as code_assist, uses built-in client for personal Google accounts // - google_one: uses configured OAuth client when provided; otherwise falls back to built-in client
// - ai_studio: requires a user-provided OAuth client // - ai_studio: requires a user-provided OAuth client
oauthCfgInput := geminicli.OAuthConfig{ oauthCfgInput := geminicli.OAuthConfig{
ClientID: c.cfg.Gemini.OAuth.ClientID, ClientID: c.cfg.Gemini.OAuth.ClientID,
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret, ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
Scopes: c.cfg.Gemini.OAuth.Scopes, Scopes: c.cfg.Gemini.OAuth.Scopes,
} }
if oauthType == "code_assist" || oauthType == "google_one" { if oauthType == "code_assist" {
oauthCfgInput.ClientID = "" oauthCfgInput.ClientID = ""
oauthCfgInput.ClientSecret = "" oauthCfgInput.ClientSecret = ""
} }
...@@ -78,7 +78,7 @@ func (c *geminiOAuthClient) RefreshToken(ctx context.Context, oauthType, refresh ...@@ -78,7 +78,7 @@ func (c *geminiOAuthClient) RefreshToken(ctx context.Context, oauthType, refresh
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret, ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
Scopes: c.cfg.Gemini.OAuth.Scopes, Scopes: c.cfg.Gemini.OAuth.Scopes,
} }
if oauthType == "code_assist" || oauthType == "google_one" { if oauthType == "code_assist" {
oauthCfgInput.ClientID = "" oauthCfgInput.ClientID = ""
oauthCfgInput.ClientSecret = "" oauthCfgInput.ClientSecret = ""
} }
......
...@@ -105,10 +105,7 @@ func (a *Account) GeminiOAuthType() string { ...@@ -105,10 +105,7 @@ func (a *Account) GeminiOAuthType() string {
func (a *Account) GeminiTierID() string { func (a *Account) GeminiTierID() string {
tierID := strings.TrimSpace(a.GetCredential("tier_id")) tierID := strings.TrimSpace(a.GetCredential("tier_id"))
if tierID == "" { return tierID
return ""
}
return strings.ToUpper(tierID)
} }
func (a *Account) IsGeminiCodeAssist() bool { func (a *Account) IsGeminiCodeAssist() bool {
......
...@@ -107,6 +107,8 @@ type UsageProgress struct { ...@@ -107,6 +107,8 @@ type UsageProgress struct {
ResetsAt *time.Time `json:"resets_at"` // 重置时间 ResetsAt *time.Time `json:"resets_at"` // 重置时间
RemainingSeconds int `json:"remaining_seconds"` // 距重置剩余秒数 RemainingSeconds int `json:"remaining_seconds"` // 距重置剩余秒数
WindowStats *WindowStats `json:"window_stats,omitempty"` // 窗口期统计(从窗口开始到当前的使用量) WindowStats *WindowStats `json:"window_stats,omitempty"` // 窗口期统计(从窗口开始到当前的使用量)
UsedRequests int64 `json:"used_requests,omitempty"`
LimitRequests int64 `json:"limit_requests,omitempty"`
} }
// AntigravityModelQuota Antigravity 单个模型的配额信息 // AntigravityModelQuota Antigravity 单个模型的配额信息
...@@ -117,12 +119,16 @@ type AntigravityModelQuota struct { ...@@ -117,12 +119,16 @@ type AntigravityModelQuota struct {
// UsageInfo 账号使用量信息 // UsageInfo 账号使用量信息
type UsageInfo struct { type UsageInfo struct {
UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间 UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口 FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口
SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口 SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口
SevenDaySonnet *UsageProgress `json:"seven_day_sonnet,omitempty"` // 7天Sonnet窗口 SevenDaySonnet *UsageProgress `json:"seven_day_sonnet,omitempty"` // 7天Sonnet窗口
GeminiProDaily *UsageProgress `json:"gemini_pro_daily,omitempty"` // Gemini Pro 日配额 GeminiSharedDaily *UsageProgress `json:"gemini_shared_daily,omitempty"` // Gemini shared pool RPD (Google One / Code Assist)
GeminiFlashDaily *UsageProgress `json:"gemini_flash_daily,omitempty"` // Gemini Flash 日配额 GeminiProDaily *UsageProgress `json:"gemini_pro_daily,omitempty"` // Gemini Pro 日配额
GeminiFlashDaily *UsageProgress `json:"gemini_flash_daily,omitempty"` // Gemini Flash 日配额
GeminiSharedMinute *UsageProgress `json:"gemini_shared_minute,omitempty"` // Gemini shared pool RPM (Google One / Code Assist)
GeminiProMinute *UsageProgress `json:"gemini_pro_minute,omitempty"` // Gemini Pro RPM
GeminiFlashMinute *UsageProgress `json:"gemini_flash_minute,omitempty"` // Gemini Flash RPM
// Antigravity 多模型配额 // Antigravity 多模型配额
AntigravityQuota map[string]*AntigravityModelQuota `json:"antigravity_quota,omitempty"` AntigravityQuota map[string]*AntigravityModelQuota `json:"antigravity_quota,omitempty"`
...@@ -258,17 +264,44 @@ func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Accou ...@@ -258,17 +264,44 @@ func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Accou
return usage, nil return usage, nil
} }
start := geminiDailyWindowStart(now) dayStart := geminiDailyWindowStart(now)
stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID) stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, dayStart, now, 0, 0, account.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("get gemini usage stats failed: %w", err) return nil, fmt.Errorf("get gemini usage stats failed: %w", err)
} }
totals := geminiAggregateUsage(stats) dayTotals := geminiAggregateUsage(stats)
resetAt := geminiDailyResetTime(now) dailyResetAt := geminiDailyResetTime(now)
usage.GeminiProDaily = buildGeminiUsageProgress(totals.ProRequests, quota.ProRPD, resetAt, totals.ProTokens, totals.ProCost, now) // Daily window (RPD)
usage.GeminiFlashDaily = buildGeminiUsageProgress(totals.FlashRequests, quota.FlashRPD, resetAt, totals.FlashTokens, totals.FlashCost, now) if quota.SharedRPD > 0 {
totalReq := dayTotals.ProRequests + dayTotals.FlashRequests
totalTokens := dayTotals.ProTokens + dayTotals.FlashTokens
totalCost := dayTotals.ProCost + dayTotals.FlashCost
usage.GeminiSharedDaily = buildGeminiUsageProgress(totalReq, quota.SharedRPD, dailyResetAt, totalTokens, totalCost, now)
} else {
usage.GeminiProDaily = buildGeminiUsageProgress(dayTotals.ProRequests, quota.ProRPD, dailyResetAt, dayTotals.ProTokens, dayTotals.ProCost, now)
usage.GeminiFlashDaily = buildGeminiUsageProgress(dayTotals.FlashRequests, quota.FlashRPD, dailyResetAt, dayTotals.FlashTokens, dayTotals.FlashCost, now)
}
// Minute window (RPM) - fixed-window approximation: current minute [truncate(now), truncate(now)+1m)
minuteStart := now.Truncate(time.Minute)
minuteResetAt := minuteStart.Add(time.Minute)
minuteStats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, minuteStart, now, 0, 0, account.ID)
if err != nil {
return nil, fmt.Errorf("get gemini minute usage stats failed: %w", err)
}
minuteTotals := geminiAggregateUsage(minuteStats)
if quota.SharedRPM > 0 {
totalReq := minuteTotals.ProRequests + minuteTotals.FlashRequests
totalTokens := minuteTotals.ProTokens + minuteTotals.FlashTokens
totalCost := minuteTotals.ProCost + minuteTotals.FlashCost
usage.GeminiSharedMinute = buildGeminiUsageProgress(totalReq, quota.SharedRPM, minuteResetAt, totalTokens, totalCost, now)
} else {
usage.GeminiProMinute = buildGeminiUsageProgress(minuteTotals.ProRequests, quota.ProRPM, minuteResetAt, minuteTotals.ProTokens, minuteTotals.ProCost, now)
usage.GeminiFlashMinute = buildGeminiUsageProgress(minuteTotals.FlashRequests, quota.FlashRPM, minuteResetAt, minuteTotals.FlashTokens, minuteTotals.FlashCost, now)
}
return usage, nil return usage, nil
} }
...@@ -508,6 +541,7 @@ func (s *AccountUsageService) estimateSetupTokenUsage(account *Account) *UsageIn ...@@ -508,6 +541,7 @@ func (s *AccountUsageService) estimateSetupTokenUsage(account *Account) *UsageIn
} }
func buildGeminiUsageProgress(used, limit int64, resetAt time.Time, tokens int64, cost float64, now time.Time) *UsageProgress { func buildGeminiUsageProgress(used, limit int64, resetAt time.Time, tokens int64, cost float64, now time.Time) *UsageProgress {
// limit <= 0 means "no local quota window" (unknown or unlimited).
if limit <= 0 { if limit <= 0 {
return nil return nil
} }
...@@ -521,6 +555,8 @@ func buildGeminiUsageProgress(used, limit int64, resetAt time.Time, tokens int64 ...@@ -521,6 +555,8 @@ func buildGeminiUsageProgress(used, limit int64, resetAt time.Time, tokens int64
Utilization: utilization, Utilization: utilization,
ResetsAt: &resetCopy, ResetsAt: &resetCopy,
RemainingSeconds: remainingSeconds, RemainingSeconds: remainingSeconds,
UsedRequests: used,
LimitRequests: limit,
WindowStats: &WindowStats{ WindowStats: &WindowStats{
Requests: used, Requests: used,
Tokens: tokens, Tokens: tokens,
......
...@@ -1064,6 +1064,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A ...@@ -1064,6 +1064,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
} }
// 不需要重试(成功或不可重试的错误),跳出循环 // 不需要重试(成功或不可重试的错误),跳出循环
// DEBUG: 输出响应 headers(用于检测 rate limit 信息)
if account.Platform == PlatformGemini && resp.StatusCode < 400 {
log.Printf("[DEBUG] Gemini API Response Headers for account %d:", account.ID)
for k, v := range resp.Header {
log.Printf("[DEBUG] %s: %v", k, v)
}
}
break break
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
......
...@@ -1628,6 +1628,15 @@ type UpstreamHTTPResult struct { ...@@ -1628,6 +1628,15 @@ type UpstreamHTTPResult struct {
} }
func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Context, resp *http.Response, isOAuth bool) (*ClaudeUsage, error) { func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Context, resp *http.Response, isOAuth bool) (*ClaudeUsage, error) {
// Log response headers for debugging
log.Printf("[GeminiAPI] ========== Response Headers ==========")
for key, values := range resp.Header {
if strings.HasPrefix(strings.ToLower(key), "x-ratelimit") {
log.Printf("[GeminiAPI] %s: %v", key, values)
}
}
log.Printf("[GeminiAPI] ========================================")
respBody, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, err return nil, err
...@@ -1658,6 +1667,15 @@ func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Co ...@@ -1658,6 +1667,15 @@ func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Co
} }
func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time, isOAuth bool) (*geminiNativeStreamResult, error) { func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time, isOAuth bool) (*geminiNativeStreamResult, error) {
// Log response headers for debugging
log.Printf("[GeminiAPI] ========== Streaming Response Headers ==========")
for key, values := range resp.Header {
if strings.HasPrefix(strings.ToLower(key), "x-ratelimit") {
log.Printf("[GeminiAPI] %s: %v", key, values)
}
}
log.Printf("[GeminiAPI] ====================================================")
c.Status(resp.StatusCode) c.Status(resp.StatusCode)
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive") c.Header("Connection", "keep-alive")
......
package service package service
import "testing" import (
"context"
func TestInferGoogleOneTier(t *testing.T) { "net/url"
tests := []struct { "strings"
name string "testing"
storageBytes int64
expectedTier string "github.com/Wei-Shaw/sub2api/internal/config"
}{ "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
{"Negative storage", -1, TierGoogleOneUnknown}, )
{"Zero storage", 0, TierGoogleOneUnknown},
func TestGeminiOAuthService_GenerateAuthURL_RedirectURIStrategy(t *testing.T) {
// Free tier boundary (15GB) t.Parallel()
{"Below free tier", 10 * GB, TierGoogleOneUnknown},
{"Just below free tier", StorageTierFree - 1, TierGoogleOneUnknown}, type testCase struct {
{"Free tier (15GB)", StorageTierFree, TierFree}, name string
cfg *config.Config
// Basic tier boundary (100GB) oauthType string
{"Between free and basic", 50 * GB, TierFree}, projectID string
{"Just below basic tier", StorageTierBasic - 1, TierFree}, wantClientID string
{"Basic tier (100GB)", StorageTierBasic, TierGoogleOneBasic}, wantRedirect string
wantScope string
// Standard tier boundary (200GB) wantProjectID string
{"Between basic and standard", 150 * GB, TierGoogleOneBasic}, wantErrSubstr string
{"Just below standard tier", StorageTierStandard - 1, TierGoogleOneBasic}, }
{"Standard tier (200GB)", StorageTierStandard, TierGoogleOneStandard},
tests := []testCase{
// AI Premium tier boundary (2TB) {
{"Between standard and premium", 1 * TB, TierGoogleOneStandard}, name: "google_one uses built-in client when not configured and redirects to upstream",
{"Just below AI Premium tier", StorageTierAIPremium - 1, TierGoogleOneStandard}, cfg: &config.Config{
{"AI Premium tier (2TB)", StorageTierAIPremium, TierAIPremium}, Gemini: config.GeminiConfig{
OAuth: config.GeminiOAuthConfig{},
// Unlimited tier boundary (> 100TB) },
{"Between premium and unlimited", 50 * TB, TierAIPremium}, },
{"At unlimited threshold (100TB)", StorageTierUnlimited, TierAIPremium}, oauthType: "google_one",
{"Unlimited tier (100TB+)", StorageTierUnlimited + 1, TierGoogleOneUnlimited}, wantClientID: geminicli.GeminiCLIOAuthClientID,
{"Unlimited tier (101TB+)", 101 * TB, TierGoogleOneUnlimited}, wantRedirect: geminicli.GeminiCLIRedirectURI,
{"Very large storage", 1000 * TB, TierGoogleOneUnlimited}, wantScope: geminicli.DefaultCodeAssistScopes,
wantProjectID: "",
},
{
name: "google_one uses custom client when configured and redirects to localhost",
cfg: &config.Config{
Gemini: config.GeminiConfig{
OAuth: config.GeminiOAuthConfig{
ClientID: "custom-client-id",
ClientSecret: "custom-client-secret",
},
},
},
oauthType: "google_one",
wantClientID: "custom-client-id",
wantRedirect: geminicli.AIStudioOAuthRedirectURI,
wantScope: geminicli.DefaultGoogleOneScopes,
wantProjectID: "",
},
{
name: "code_assist always forces built-in client even when custom client configured",
cfg: &config.Config{
Gemini: config.GeminiConfig{
OAuth: config.GeminiOAuthConfig{
ClientID: "custom-client-id",
ClientSecret: "custom-client-secret",
},
},
},
oauthType: "code_assist",
projectID: "my-gcp-project",
wantClientID: geminicli.GeminiCLIOAuthClientID,
wantRedirect: geminicli.GeminiCLIRedirectURI,
wantScope: geminicli.DefaultCodeAssistScopes,
wantProjectID: "my-gcp-project",
},
{
name: "ai_studio requires custom client",
cfg: &config.Config{
Gemini: config.GeminiConfig{
OAuth: config.GeminiOAuthConfig{},
},
},
oauthType: "ai_studio",
wantErrSubstr: "AI Studio OAuth requires a custom OAuth Client",
},
} }
for _, tt := range tests { for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := inferGoogleOneTier(tt.storageBytes) t.Parallel()
if result != tt.expectedTier {
t.Errorf("inferGoogleOneTier(%d) = %s, want %s", svc := NewGeminiOAuthService(nil, nil, nil, tt.cfg)
tt.storageBytes, result, tt.expectedTier) got, err := svc.GenerateAuthURL(context.Background(), nil, "https://example.com/auth/callback", tt.projectID, tt.oauthType, "")
if tt.wantErrSubstr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr)
}
if !strings.Contains(err.Error(), tt.wantErrSubstr) {
t.Fatalf("expected error containing %q, got: %v", tt.wantErrSubstr, err)
}
return
}
if err != nil {
t.Fatalf("GenerateAuthURL returned error: %v", err)
}
parsed, err := url.Parse(got.AuthURL)
if err != nil {
t.Fatalf("failed to parse auth_url: %v", err)
}
q := parsed.Query()
if gotState := q.Get("state"); gotState != got.State {
t.Fatalf("state mismatch: query=%q result=%q", gotState, got.State)
}
if gotClientID := q.Get("client_id"); gotClientID != tt.wantClientID {
t.Fatalf("client_id mismatch: got=%q want=%q", gotClientID, tt.wantClientID)
}
if gotRedirect := q.Get("redirect_uri"); gotRedirect != tt.wantRedirect {
t.Fatalf("redirect_uri mismatch: got=%q want=%q", gotRedirect, tt.wantRedirect)
}
if gotScope := q.Get("scope"); gotScope != tt.wantScope {
t.Fatalf("scope mismatch: got=%q want=%q", gotScope, tt.wantScope)
}
if gotProjectID := q.Get("project_id"); gotProjectID != tt.wantProjectID {
t.Fatalf("project_id mismatch: got=%q want=%q", gotProjectID, tt.wantProjectID)
} }
}) })
} }
......
...@@ -20,13 +20,24 @@ const ( ...@@ -20,13 +20,24 @@ const (
geminiModelFlash geminiModelClass = "flash" geminiModelFlash geminiModelClass = "flash"
) )
type GeminiDailyQuota struct { type GeminiQuota struct {
ProRPD int64 // SharedRPD is a shared requests-per-day pool across models.
FlashRPD int64 // When SharedRPD > 0, callers should treat ProRPD/FlashRPD as not applicable for daily quota checks.
SharedRPD int64 `json:"shared_rpd,omitempty"`
// SharedRPM is a shared requests-per-minute pool across models.
// When SharedRPM > 0, callers should treat ProRPM/FlashRPM as not applicable for minute quota checks.
SharedRPM int64 `json:"shared_rpm,omitempty"`
// Per-model quotas (AI Studio / API key).
// A value of -1 means "unlimited" (pay-as-you-go).
ProRPD int64 `json:"pro_rpd,omitempty"`
ProRPM int64 `json:"pro_rpm,omitempty"`
FlashRPD int64 `json:"flash_rpd,omitempty"`
FlashRPM int64 `json:"flash_rpm,omitempty"`
} }
type GeminiTierPolicy struct { type GeminiTierPolicy struct {
Quota GeminiDailyQuota Quota GeminiQuota
Cooldown time.Duration Cooldown time.Duration
} }
...@@ -45,10 +56,27 @@ type GeminiUsageTotals struct { ...@@ -45,10 +56,27 @@ type GeminiUsageTotals struct {
const geminiQuotaCacheTTL = time.Minute const geminiQuotaCacheTTL = time.Minute
type geminiQuotaOverrides struct { type geminiQuotaOverridesV1 struct {
Tiers map[string]config.GeminiTierQuotaConfig `json:"tiers"` Tiers map[string]config.GeminiTierQuotaConfig `json:"tiers"`
} }
type geminiQuotaOverridesV2 struct {
QuotaRules map[string]geminiQuotaRuleOverride `json:"quota_rules"`
}
type geminiQuotaRuleOverride struct {
SharedRPD *int64 `json:"shared_rpd,omitempty"`
SharedRPM *int64 `json:"rpm,omitempty"`
GeminiPro *geminiModelQuotaOverride `json:"gemini_pro,omitempty"`
GeminiFlash *geminiModelQuotaOverride `json:"gemini_flash,omitempty"`
Desc *string `json:"desc,omitempty"`
}
type geminiModelQuotaOverride struct {
RPD *int64 `json:"rpd,omitempty"`
RPM *int64 `json:"rpm,omitempty"`
}
type GeminiQuotaService struct { type GeminiQuotaService struct {
cfg *config.Config cfg *config.Config
settingRepo SettingRepository settingRepo SettingRepository
...@@ -82,11 +110,17 @@ func (s *GeminiQuotaService) Policy(ctx context.Context) *GeminiQuotaPolicy { ...@@ -82,11 +110,17 @@ func (s *GeminiQuotaService) Policy(ctx context.Context) *GeminiQuotaPolicy {
if s.cfg != nil { if s.cfg != nil {
policy.ApplyOverrides(s.cfg.Gemini.Quota.Tiers) policy.ApplyOverrides(s.cfg.Gemini.Quota.Tiers)
if strings.TrimSpace(s.cfg.Gemini.Quota.Policy) != "" { if strings.TrimSpace(s.cfg.Gemini.Quota.Policy) != "" {
var overrides geminiQuotaOverrides raw := []byte(s.cfg.Gemini.Quota.Policy)
if err := json.Unmarshal([]byte(s.cfg.Gemini.Quota.Policy), &overrides); err != nil { var overridesV2 geminiQuotaOverridesV2
log.Printf("gemini quota: parse config policy failed: %v", err) if err := json.Unmarshal(raw, &overridesV2); err == nil && len(overridesV2.QuotaRules) > 0 {
policy.ApplyQuotaRulesOverrides(overridesV2.QuotaRules)
} else { } else {
policy.ApplyOverrides(overrides.Tiers) var overridesV1 geminiQuotaOverridesV1
if err := json.Unmarshal(raw, &overridesV1); err != nil {
log.Printf("gemini quota: parse config policy failed: %v", err)
} else {
policy.ApplyOverrides(overridesV1.Tiers)
}
} }
} }
} }
...@@ -96,11 +130,17 @@ func (s *GeminiQuotaService) Policy(ctx context.Context) *GeminiQuotaPolicy { ...@@ -96,11 +130,17 @@ func (s *GeminiQuotaService) Policy(ctx context.Context) *GeminiQuotaPolicy {
if err != nil && !errors.Is(err, ErrSettingNotFound) { if err != nil && !errors.Is(err, ErrSettingNotFound) {
log.Printf("gemini quota: load setting failed: %v", err) log.Printf("gemini quota: load setting failed: %v", err)
} else if strings.TrimSpace(value) != "" { } else if strings.TrimSpace(value) != "" {
var overrides geminiQuotaOverrides raw := []byte(value)
if err := json.Unmarshal([]byte(value), &overrides); err != nil { var overridesV2 geminiQuotaOverridesV2
log.Printf("gemini quota: parse setting failed: %v", err) if err := json.Unmarshal(raw, &overridesV2); err == nil && len(overridesV2.QuotaRules) > 0 {
policy.ApplyQuotaRulesOverrides(overridesV2.QuotaRules)
} else { } else {
policy.ApplyOverrides(overrides.Tiers) var overridesV1 geminiQuotaOverridesV1
if err := json.Unmarshal(raw, &overridesV1); err != nil {
log.Printf("gemini quota: parse setting failed: %v", err)
} else {
policy.ApplyOverrides(overridesV1.Tiers)
}
} }
} }
} }
...@@ -113,12 +153,20 @@ func (s *GeminiQuotaService) Policy(ctx context.Context) *GeminiQuotaPolicy { ...@@ -113,12 +153,20 @@ func (s *GeminiQuotaService) Policy(ctx context.Context) *GeminiQuotaPolicy {
return policy return policy
} }
func (s *GeminiQuotaService) QuotaForAccount(ctx context.Context, account *Account) (GeminiDailyQuota, bool) { func (s *GeminiQuotaService) QuotaForAccount(ctx context.Context, account *Account) (GeminiQuota, bool) {
if account == nil || !account.IsGeminiCodeAssist() { if account == nil || account.Platform != PlatformGemini {
return GeminiDailyQuota{}, false return GeminiQuota{}, false
}
// Map (oauth_type + tier_id) to a canonical policy tier key.
// This keeps the policy table stable even if upstream tier_id strings vary.
tierKey := geminiQuotaTierKeyForAccount(account)
if tierKey == "" {
return GeminiQuota{}, false
} }
policy := s.Policy(ctx) policy := s.Policy(ctx)
return policy.QuotaForTier(account.GeminiTierID()) return policy.QuotaForTier(tierKey)
} }
func (s *GeminiQuotaService) CooldownForTier(ctx context.Context, tierID string) time.Duration { func (s *GeminiQuotaService) CooldownForTier(ctx context.Context, tierID string) time.Duration {
...@@ -126,12 +174,36 @@ func (s *GeminiQuotaService) CooldownForTier(ctx context.Context, tierID string) ...@@ -126,12 +174,36 @@ func (s *GeminiQuotaService) CooldownForTier(ctx context.Context, tierID string)
return policy.CooldownForTier(tierID) return policy.CooldownForTier(tierID)
} }
func (s *GeminiQuotaService) CooldownForAccount(ctx context.Context, account *Account) time.Duration {
if s == nil || account == nil || account.Platform != PlatformGemini {
return 5 * time.Minute
}
tierKey := geminiQuotaTierKeyForAccount(account)
if strings.TrimSpace(tierKey) == "" {
return 5 * time.Minute
}
return s.CooldownForTier(ctx, tierKey)
}
func newGeminiQuotaPolicy() *GeminiQuotaPolicy { func newGeminiQuotaPolicy() *GeminiQuotaPolicy {
return &GeminiQuotaPolicy{ return &GeminiQuotaPolicy{
tiers: map[string]GeminiTierPolicy{ tiers: map[string]GeminiTierPolicy{
"LEGACY": {Quota: GeminiDailyQuota{ProRPD: 50, FlashRPD: 1500}, Cooldown: 30 * time.Minute}, // --- AI Studio / API Key (per-model) ---
"PRO": {Quota: GeminiDailyQuota{ProRPD: 1500, FlashRPD: 4000}, Cooldown: 5 * time.Minute}, // aistudio_free:
"ULTRA": {Quota: GeminiDailyQuota{ProRPD: 2000, FlashRPD: 0}, Cooldown: 5 * time.Minute}, // - gemini_pro: 50 RPD / 2 RPM
// - gemini_flash: 1500 RPD / 15 RPM
GeminiTierAIStudioFree: {Quota: GeminiQuota{ProRPD: 50, ProRPM: 2, FlashRPD: 1500, FlashRPM: 15}, Cooldown: 30 * time.Minute},
// aistudio_paid: -1 means "unlimited/pay-as-you-go" for RPD.
GeminiTierAIStudioPaid: {Quota: GeminiQuota{ProRPD: -1, ProRPM: 1000, FlashRPD: -1, FlashRPM: 2000}, Cooldown: 5 * time.Minute},
// --- Google One (shared pool) ---
GeminiTierGoogleOneFree: {Quota: GeminiQuota{SharedRPD: 1000, SharedRPM: 60}, Cooldown: 30 * time.Minute},
GeminiTierGoogleAIPro: {Quota: GeminiQuota{SharedRPD: 1500, SharedRPM: 120}, Cooldown: 5 * time.Minute},
GeminiTierGoogleAIUltra: {Quota: GeminiQuota{SharedRPD: 2000, SharedRPM: 120}, Cooldown: 5 * time.Minute},
// --- GCP Code Assist (shared pool) ---
GeminiTierGCPStandard: {Quota: GeminiQuota{SharedRPD: 1500, SharedRPM: 120}, Cooldown: 5 * time.Minute},
GeminiTierGCPEnterprise: {Quota: GeminiQuota{SharedRPD: 2000, SharedRPM: 120}, Cooldown: 5 * time.Minute},
}, },
} }
} }
...@@ -149,11 +221,22 @@ func (p *GeminiQuotaPolicy) ApplyOverrides(tiers map[string]config.GeminiTierQuo ...@@ -149,11 +221,22 @@ func (p *GeminiQuotaPolicy) ApplyOverrides(tiers map[string]config.GeminiTierQuo
if !ok { if !ok {
policy = GeminiTierPolicy{Cooldown: 5 * time.Minute} policy = GeminiTierPolicy{Cooldown: 5 * time.Minute}
} }
// Backward-compatible overrides:
// - If the tier uses shared quota, interpret pro_rpd as shared_rpd.
// - Otherwise apply per-model overrides.
if override.ProRPD != nil { if override.ProRPD != nil {
policy.Quota.ProRPD = clampGeminiQuotaInt64(*override.ProRPD) if policy.Quota.SharedRPD > 0 {
policy.Quota.SharedRPD = clampGeminiQuotaInt64WithUnlimited(*override.ProRPD)
} else {
policy.Quota.ProRPD = clampGeminiQuotaInt64WithUnlimited(*override.ProRPD)
}
} }
if override.FlashRPD != nil { if override.FlashRPD != nil {
policy.Quota.FlashRPD = clampGeminiQuotaInt64(*override.FlashRPD) if policy.Quota.SharedRPD > 0 {
// No separate flash RPD for shared tiers.
} else {
policy.Quota.FlashRPD = clampGeminiQuotaInt64WithUnlimited(*override.FlashRPD)
}
} }
if override.CooldownMinutes != nil { if override.CooldownMinutes != nil {
minutes := clampGeminiQuotaInt(*override.CooldownMinutes) minutes := clampGeminiQuotaInt(*override.CooldownMinutes)
...@@ -163,10 +246,51 @@ func (p *GeminiQuotaPolicy) ApplyOverrides(tiers map[string]config.GeminiTierQuo ...@@ -163,10 +246,51 @@ func (p *GeminiQuotaPolicy) ApplyOverrides(tiers map[string]config.GeminiTierQuo
} }
} }
func (p *GeminiQuotaPolicy) QuotaForTier(tierID string) (GeminiDailyQuota, bool) { func (p *GeminiQuotaPolicy) ApplyQuotaRulesOverrides(rules map[string]geminiQuotaRuleOverride) {
if p == nil || len(rules) == 0 {
return
}
for rawID, override := range rules {
tierID := normalizeGeminiTierID(rawID)
if tierID == "" {
continue
}
policy, ok := p.tiers[tierID]
if !ok {
policy = GeminiTierPolicy{Cooldown: 5 * time.Minute}
}
if override.SharedRPD != nil {
policy.Quota.SharedRPD = clampGeminiQuotaInt64WithUnlimited(*override.SharedRPD)
}
if override.SharedRPM != nil {
policy.Quota.SharedRPM = clampGeminiQuotaRPM(*override.SharedRPM)
}
if override.GeminiPro != nil {
if override.GeminiPro.RPD != nil {
policy.Quota.ProRPD = clampGeminiQuotaInt64WithUnlimited(*override.GeminiPro.RPD)
}
if override.GeminiPro.RPM != nil {
policy.Quota.ProRPM = clampGeminiQuotaRPM(*override.GeminiPro.RPM)
}
}
if override.GeminiFlash != nil {
if override.GeminiFlash.RPD != nil {
policy.Quota.FlashRPD = clampGeminiQuotaInt64WithUnlimited(*override.GeminiFlash.RPD)
}
if override.GeminiFlash.RPM != nil {
policy.Quota.FlashRPM = clampGeminiQuotaRPM(*override.GeminiFlash.RPM)
}
}
p.tiers[tierID] = policy
}
}
func (p *GeminiQuotaPolicy) QuotaForTier(tierID string) (GeminiQuota, bool) {
policy, ok := p.policyForTier(tierID) policy, ok := p.policyForTier(tierID)
if !ok { if !ok {
return GeminiDailyQuota{}, false return GeminiQuota{}, false
} }
return policy.Quota, true return policy.Quota, true
} }
...@@ -184,22 +308,43 @@ func (p *GeminiQuotaPolicy) policyForTier(tierID string) (GeminiTierPolicy, bool ...@@ -184,22 +308,43 @@ func (p *GeminiQuotaPolicy) policyForTier(tierID string) (GeminiTierPolicy, bool
return GeminiTierPolicy{}, false return GeminiTierPolicy{}, false
} }
normalized := normalizeGeminiTierID(tierID) normalized := normalizeGeminiTierID(tierID)
if normalized == "" {
normalized = "LEGACY"
}
if policy, ok := p.tiers[normalized]; ok { if policy, ok := p.tiers[normalized]; ok {
return policy, true return policy, true
} }
policy, ok := p.tiers["LEGACY"] return GeminiTierPolicy{}, false
return policy, ok
} }
func normalizeGeminiTierID(tierID string) string { func normalizeGeminiTierID(tierID string) string {
return strings.ToUpper(strings.TrimSpace(tierID)) tierID = strings.TrimSpace(tierID)
if tierID == "" {
return ""
}
// Prefer canonical mapping (handles legacy tier strings).
if canonical := canonicalGeminiTierID(tierID); canonical != "" {
return canonical
}
// Accept older policy keys that used uppercase names.
switch strings.ToUpper(tierID) {
case "AISTUDIO_FREE":
return GeminiTierAIStudioFree
case "AISTUDIO_PAID":
return GeminiTierAIStudioPaid
case "GOOGLE_ONE_FREE":
return GeminiTierGoogleOneFree
case "GOOGLE_AI_PRO":
return GeminiTierGoogleAIPro
case "GOOGLE_AI_ULTRA":
return GeminiTierGoogleAIUltra
case "GCP_STANDARD":
return GeminiTierGCPStandard
case "GCP_ENTERPRISE":
return GeminiTierGCPEnterprise
}
return strings.ToLower(tierID)
} }
func clampGeminiQuotaInt64(value int64) int64 { func clampGeminiQuotaInt64WithUnlimited(value int64) int64 {
if value < 0 { if value < -1 {
return 0 return 0
} }
return value return value
...@@ -212,11 +357,46 @@ func clampGeminiQuotaInt(value int) int { ...@@ -212,11 +357,46 @@ func clampGeminiQuotaInt(value int) int {
return value return value
} }
func clampGeminiQuotaRPM(value int64) int64 {
if value < 0 {
return 0
}
return value
}
func geminiCooldownForTier(tierID string) time.Duration { func geminiCooldownForTier(tierID string) time.Duration {
policy := newGeminiQuotaPolicy() policy := newGeminiQuotaPolicy()
return policy.CooldownForTier(tierID) return policy.CooldownForTier(tierID)
} }
func geminiQuotaTierKeyForAccount(account *Account) string {
if account == nil || account.Platform != PlatformGemini {
return ""
}
// Note: GeminiOAuthType() already defaults legacy (project_id present) to code_assist.
oauthType := strings.ToLower(strings.TrimSpace(account.GeminiOAuthType()))
rawTier := strings.TrimSpace(account.GeminiTierID())
// Prefer the canonical tier stored in credentials.
if tierID := canonicalGeminiTierIDForOAuthType(oauthType, rawTier); tierID != "" && tierID != GeminiTierGoogleOneUnknown {
return tierID
}
// Fallback defaults when tier_id is missing or unknown.
switch oauthType {
case "google_one":
return GeminiTierGoogleOneFree
case "code_assist":
return GeminiTierGCPStandard
case "ai_studio":
return GeminiTierAIStudioFree
default:
// API Key accounts (type=apikey) have empty oauth_type and are treated as AI Studio.
return GeminiTierAIStudioFree
}
}
func geminiModelClassFromName(model string) geminiModelClass { func geminiModelClassFromName(model string) geminiModelClass {
name := strings.ToLower(strings.TrimSpace(model)) name := strings.ToLower(strings.TrimSpace(model))
if strings.Contains(name, "flash") || strings.Contains(name, "lite") { if strings.Contains(name, "flash") || strings.Contains(name, "lite") {
......
...@@ -92,7 +92,7 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc ...@@ -92,7 +92,7 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
// PreCheckUsage proactively checks local quota before dispatching a request. // PreCheckUsage proactively checks local quota before dispatching a request.
// Returns false when the account should be skipped. // Returns false when the account should be skipped.
func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account, requestedModel string) (bool, error) { func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account, requestedModel string) (bool, error) {
if account == nil || !account.IsGeminiCodeAssist() || strings.TrimSpace(requestedModel) == "" { if account == nil || account.Platform != PlatformGemini {
return true, nil return true, nil
} }
if s.usageRepo == nil || s.geminiQuotaService == nil { if s.usageRepo == nil || s.geminiQuotaService == nil {
...@@ -104,44 +104,99 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account, ...@@ -104,44 +104,99 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account,
return true, nil return true, nil
} }
var limit int64
switch geminiModelClassFromName(requestedModel) {
case geminiModelFlash:
limit = quota.FlashRPD
default:
limit = quota.ProRPD
}
if limit <= 0 {
return true, nil
}
now := time.Now() now := time.Now()
start := geminiDailyWindowStart(now) modelClass := geminiModelClassFromName(requestedModel)
totals, ok := s.getGeminiUsageTotals(account.ID, start, now)
if !ok { // 1) Daily quota precheck (RPD; resets at PST midnight)
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID) {
if err != nil { var limit int64
return true, err if quota.SharedRPD > 0 {
limit = quota.SharedRPD
} else {
switch modelClass {
case geminiModelFlash:
limit = quota.FlashRPD
default:
limit = quota.ProRPD
}
} }
totals = geminiAggregateUsage(stats)
s.setGeminiUsageTotals(account.ID, start, now, totals)
}
var used int64 if limit > 0 {
switch geminiModelClassFromName(requestedModel) { start := geminiDailyWindowStart(now)
case geminiModelFlash: totals, ok := s.getGeminiUsageTotals(account.ID, start, now)
used = totals.FlashRequests if !ok {
default: stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
used = totals.ProRequests if err != nil {
return true, err
}
totals = geminiAggregateUsage(stats)
s.setGeminiUsageTotals(account.ID, start, now, totals)
}
var used int64
if quota.SharedRPD > 0 {
used = totals.ProRequests + totals.FlashRequests
} else {
switch modelClass {
case geminiModelFlash:
used = totals.FlashRequests
default:
used = totals.ProRequests
}
}
if used >= limit {
resetAt := geminiDailyResetTime(now)
// NOTE:
// - This is a local precheck to reduce upstream 429s.
// - Do NOT mark the account as rate-limited here; rate_limit_reset_at should reflect real upstream 429s.
log.Printf("[Gemini PreCheck] Account %d reached daily quota (%d/%d), skip until %v", account.ID, used, limit, resetAt)
return false, nil
}
}
} }
if used >= limit { // 2) Minute quota precheck (RPM; fixed window current minute)
resetAt := geminiDailyResetTime(now) {
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil { var limit int64
log.Printf("SetRateLimited failed for account %d: %v", account.ID, err) if quota.SharedRPM > 0 {
limit = quota.SharedRPM
} else {
switch modelClass {
case geminiModelFlash:
limit = quota.FlashRPM
default:
limit = quota.ProRPM
}
}
if limit > 0 {
start := now.Truncate(time.Minute)
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
if err != nil {
return true, err
}
totals := geminiAggregateUsage(stats)
var used int64
if quota.SharedRPM > 0 {
used = totals.ProRequests + totals.FlashRequests
} else {
switch modelClass {
case geminiModelFlash:
used = totals.FlashRequests
default:
used = totals.ProRequests
}
}
if used >= limit {
resetAt := start.Add(time.Minute)
// Do not persist "rate limited" status from local precheck. See note above.
log.Printf("[Gemini PreCheck] Account %d reached minute quota (%d/%d), skip until %v", account.ID, used, limit, resetAt)
return false, nil
}
} }
log.Printf("[Gemini PreCheck] Account %d reached daily quota (%d/%d), rate limited until %v", account.ID, used, limit, resetAt)
return false, nil
} }
return true, nil return true, nil
...@@ -186,7 +241,10 @@ func (s *RateLimitService) GeminiCooldown(ctx context.Context, account *Account) ...@@ -186,7 +241,10 @@ func (s *RateLimitService) GeminiCooldown(ctx context.Context, account *Account)
if account == nil { if account == nil {
return 5 * time.Minute return 5 * time.Minute
} }
return s.geminiQuotaService.CooldownForTier(ctx, account.GeminiTierID()) if s.geminiQuotaService == nil {
return 5 * time.Minute
}
return s.geminiQuotaService.CooldownForAccount(ctx, account)
} }
// handleAuthError 处理认证类错误(401/403),停止账号调度 // handleAuthError 处理认证类错误(401/403),停止账号调度
......
...@@ -20,6 +20,7 @@ export interface GeminiAuthUrlRequest { ...@@ -20,6 +20,7 @@ export interface GeminiAuthUrlRequest {
proxy_id?: number proxy_id?: number
project_id?: string project_id?: string
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio' oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
tier_id?: string
} }
export interface GeminiExchangeCodeRequest { export interface GeminiExchangeCodeRequest {
...@@ -28,6 +29,7 @@ export interface GeminiExchangeCodeRequest { ...@@ -28,6 +29,7 @@ export interface GeminiExchangeCodeRequest {
code: string code: string
proxy_id?: number proxy_id?: number
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio' oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
tier_id?: string
} }
export type GeminiTokenInfo = { export type GeminiTokenInfo = {
......
...@@ -65,32 +65,33 @@ const tierLabel = computed(() => { ...@@ -65,32 +65,33 @@ const tierLabel = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined const creds = props.account.credentials as GeminiCredentials | undefined
if (isCodeAssist.value) { if (isCodeAssist.value) {
// GCP Code Assist: 显示 GCP tier const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
const tierMap: Record<string, string> = { if (tier === 'gcp_enterprise') return 'GCP Enterprise'
LEGACY: 'Free', if (tier === 'gcp_standard') return 'GCP Standard'
PRO: 'Pro', // Backward compatibility
ULTRA: 'Ultra', const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
'standard-tier': 'Standard', if (upper.includes('ULTRA') || upper.includes('ENTERPRISE')) return 'GCP Enterprise'
'pro-tier': 'Pro', if (upper) return `GCP ${upper}`
'ultra-tier': 'Ultra' return 'GCP'
}
return tierMap[creds?.tier_id || ''] || (creds?.tier_id ? 'GCP' : 'Unknown')
} }
if (isGoogleOne.value) { if (isGoogleOne.value) {
// Google One: tier 映射 const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
const tierMap: Record<string, string> = { if (tier === 'google_ai_ultra') return 'Google AI Ultra'
AI_PREMIUM: 'AI Premium', if (tier === 'google_ai_pro') return 'Google AI Pro'
GOOGLE_ONE_STANDARD: 'Standard', if (tier === 'google_one_free') return 'Google One Free'
GOOGLE_ONE_BASIC: 'Basic', // Backward compatibility
FREE: 'Free', const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
GOOGLE_ONE_UNKNOWN: 'Personal', if (upper === 'AI_PREMIUM') return 'Google AI Pro'
GOOGLE_ONE_UNLIMITED: 'Unlimited' if (upper === 'GOOGLE_ONE_UNLIMITED') return 'Google AI Ultra'
} if (upper) return `Google One ${upper}`
return tierMap[creds?.tier_id || ''] || 'Personal' return 'Google One'
} }
// API Key: 显示 AI Studio // API Key: 显示 AI Studio
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'aistudio_paid') return 'AI Studio Pay-as-you-go'
if (tier === 'aistudio_free') return 'AI Studio Free Tier'
return 'AI Studio' return 'AI Studio'
}) })
...@@ -99,35 +100,31 @@ const tierBadgeClass = computed(() => { ...@@ -99,35 +100,31 @@ const tierBadgeClass = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined const creds = props.account.credentials as GeminiCredentials | undefined
if (isCodeAssist.value) { if (isCodeAssist.value) {
// GCP Code Assist 样式 const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
const tierColorMap: Record<string, string> = { if (tier === 'gcp_enterprise') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
LEGACY: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300', if (tier === 'gcp_standard') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
PRO: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300', // Backward compatibility
ULTRA: 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300', const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
'standard-tier': 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-300', if (upper.includes('ULTRA') || upper.includes('ENTERPRISE')) return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
'pro-tier': 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300', return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
'ultra-tier': 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
}
return (
tierColorMap[creds?.tier_id || ''] ||
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
)
} }
if (isGoogleOne.value) { if (isGoogleOne.value) {
// Google One tier 样式 const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
const tierColorMap: Record<string, string> = { if (tier === 'google_ai_ultra') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
AI_PREMIUM: 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300', if (tier === 'google_ai_pro') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300', if (tier === 'google_one_free') return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
GOOGLE_ONE_BASIC: 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-300', // Backward compatibility
FREE: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300', const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300', if (upper === 'GOOGLE_ONE_UNLIMITED') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300' if (upper === 'AI_PREMIUM') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
} return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
return tierColorMap[creds?.tier_id || ''] || 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
} }
// AI Studio 默认样式:蓝色 // AI Studio 默认样式:蓝色
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'aistudio_paid') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
if (tier === 'aistudio_free') return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300' return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
}) })
......
...@@ -241,23 +241,16 @@ ...@@ -241,23 +241,16 @@
<div v-else-if="error" class="text-xs text-red-500"> <div v-else-if="error" class="text-xs text-red-500">
{{ error }} {{ error }}
</div> </div>
<!-- GCP & Google One: show model usage bars when available --> <!-- Gemini: show daily usage bars when available -->
<div v-else-if="geminiUsageAvailable" class="space-y-1"> <div v-else-if="geminiUsageAvailable" class="space-y-1">
<UsageProgressBar <UsageProgressBar
v-if="usageInfo?.gemini_pro_daily" v-for="bar in geminiUsageBars"
label="Pro" :key="bar.key"
:utilization="usageInfo.gemini_pro_daily.utilization" :label="bar.label"
:resets-at="usageInfo.gemini_pro_daily.resets_at" :utilization="bar.utilization"
:window-stats="usageInfo.gemini_pro_daily.window_stats" :resets-at="bar.resetsAt"
color="indigo" :window-stats="bar.windowStats"
/> :color="bar.color"
<UsageProgressBar
v-if="usageInfo?.gemini_flash_daily"
label="Flash"
:utilization="usageInfo.gemini_flash_daily.utilization"
:resets-at="usageInfo.gemini_flash_daily.resets_at"
:window-stats="usageInfo.gemini_flash_daily.window_stats"
color="emerald"
/> />
<p class="mt-1 text-[9px] leading-tight text-gray-400 dark:text-gray-500 italic"> <p class="mt-1 text-[9px] leading-tight text-gray-400 dark:text-gray-500 italic">
* {{ t('admin.accounts.gemini.quotaPolicy.simulatedNote') || 'Simulated quota' }} * {{ t('admin.accounts.gemini.quotaPolicy.simulatedNote') || 'Simulated quota' }}
...@@ -288,7 +281,7 @@ ...@@ -288,7 +281,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo, GeminiCredentials } from '@/types' import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue' import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue' import AccountQuotaInfo from './AccountQuotaInfo.vue'
...@@ -303,16 +296,18 @@ const error = ref<string | null>(null) ...@@ -303,16 +296,18 @@ const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null) const usageInfo = ref<AccountUsageInfo | null>(null)
// Show usage windows for OAuth and Setup Token accounts // Show usage windows for OAuth and Setup Token accounts
const showUsageWindows = computed( const showUsageWindows = computed(() => {
() => props.account.type === 'oauth' || props.account.type === 'setup-token' // Gemini: we can always compute local usage windows from DB logs (simulated quotas).
) if (props.account.platform === 'gemini') return true
return props.account.type === 'oauth' || props.account.type === 'setup-token'
})
const shouldFetchUsage = computed(() => { const shouldFetchUsage = computed(() => {
if (props.account.platform === 'anthropic') { if (props.account.platform === 'anthropic') {
return props.account.type === 'oauth' || props.account.type === 'setup-token' return props.account.type === 'oauth' || props.account.type === 'setup-token'
} }
if (props.account.platform === 'gemini') { if (props.account.platform === 'gemini') {
return props.account.type === 'oauth' return true
} }
if (props.account.platform === 'antigravity') { if (props.account.platform === 'antigravity') {
return props.account.type === 'oauth' return props.account.type === 'oauth'
...@@ -322,8 +317,12 @@ const shouldFetchUsage = computed(() => { ...@@ -322,8 +317,12 @@ const shouldFetchUsage = computed(() => {
const geminiUsageAvailable = computed(() => { const geminiUsageAvailable = computed(() => {
return ( return (
!!usageInfo.value?.gemini_shared_daily ||
!!usageInfo.value?.gemini_pro_daily || !!usageInfo.value?.gemini_pro_daily ||
!!usageInfo.value?.gemini_flash_daily !!usageInfo.value?.gemini_flash_daily ||
!!usageInfo.value?.gemini_shared_minute ||
!!usageInfo.value?.gemini_pro_minute ||
!!usageInfo.value?.gemini_flash_minute
) )
}) })
...@@ -569,6 +568,12 @@ const geminiTier = computed(() => { ...@@ -569,6 +568,12 @@ const geminiTier = computed(() => {
return creds?.tier_id || null return creds?.tier_id || null
}) })
const geminiOAuthType = computed(() => {
if (props.account.platform !== 'gemini') return null
const creds = props.account.credentials as GeminiCredentials | undefined
return (creds?.oauth_type || '').trim() || null
})
// Gemini 是否为 Code Assist OAuth // Gemini 是否为 Code Assist OAuth
const isGeminiCodeAssist = computed(() => { const isGeminiCodeAssist = computed(() => {
if (props.account.platform !== 'gemini') return false if (props.account.platform !== 'gemini') return false
...@@ -576,109 +581,208 @@ const isGeminiCodeAssist = computed(() => { ...@@ -576,109 +581,208 @@ const isGeminiCodeAssist = computed(() => {
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id) return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
}) })
// Gemini 认证类型 + Tier 组合标签(简洁版) const geminiChannelShort = computed((): 'ai studio' | 'gcp' | 'google one' | 'client' | null => {
const geminiAuthTypeLabel = computed(() => { if (props.account.platform !== 'gemini') return null
const creds = props.account.credentials as GeminiCredentials | undefined
const oauthType = creds?.oauth_type // API Key accounts are AI Studio.
if (props.account.type === 'apikey') return 'ai studio'
// For API Key accounts, don't show auth type label
if (props.account.type !== 'oauth') return null if (geminiOAuthType.value === 'google_one') return 'google one'
if (isGeminiCodeAssist.value) return 'gcp'
if (oauthType === 'google_one') { if (geminiOAuthType.value === 'ai_studio') return 'client'
// Google One: show "Google One" + tier
const tierMap: Record<string, string> = { // Fallback (unknown legacy data): treat as AI Studio.
AI_PREMIUM: 'AI Premium', return 'ai studio'
GOOGLE_ONE_STANDARD: 'Standard', })
GOOGLE_ONE_BASIC: 'Basic',
FREE: 'Free', const geminiUserLevel = computed((): string | null => {
GOOGLE_ONE_UNKNOWN: 'Personal', if (props.account.platform !== 'gemini') return null
GOOGLE_ONE_UNLIMITED: 'Unlimited'
} const tier = (geminiTier.value || '').toString().trim()
const tierLabel = geminiTier.value ? tierMap[geminiTier.value] || 'Personal' : 'Personal' const tierLower = tier.toLowerCase()
return `Google One ${tierLabel}` const tierUpper = tier.toUpperCase()
} else if (oauthType === 'code_assist' || (!oauthType && isGeminiCodeAssist.value)) {
// Code Assist: show "GCP" + tier // Google One: free / pro / ultra
const tierMap: Record<string, string> = { if (geminiOAuthType.value === 'google_one') {
LEGACY: 'Free', if (tierLower === 'google_one_free') return 'free'
PRO: 'Pro', if (tierLower === 'google_ai_pro') return 'pro'
ULTRA: 'Ultra' if (tierLower === 'google_ai_ultra') return 'ultra'
}
const tierLabel = geminiTier.value ? tierMap[geminiTier.value] || 'Free' : 'Free' // Backward compatibility (legacy tier markers)
return `GCP ${tierLabel}` if (tierUpper === 'AI_PREMIUM' || tierUpper === 'GOOGLE_ONE_STANDARD') return 'pro'
} else if (oauthType === 'ai_studio') { if (tierUpper === 'GOOGLE_ONE_UNLIMITED') return 'ultra'
// 自定义 OAuth Client: show "Client" (no tier) if (tierUpper === 'FREE' || tierUpper === 'GOOGLE_ONE_BASIC' || tierUpper === 'GOOGLE_ONE_UNKNOWN' || tierUpper === '') return 'free'
return 'Client'
return null
}
// GCP Code Assist: standard / enterprise
if (isGeminiCodeAssist.value) {
if (tierLower === 'gcp_enterprise') return 'enterprise'
if (tierLower === 'gcp_standard') return 'standard'
// Backward compatibility
if (tierUpper.includes('ULTRA') || tierUpper.includes('ENTERPRISE')) return 'enterprise'
return 'standard'
}
// AI Studio (API Key) and Client OAuth: free / paid
if (props.account.type === 'apikey' || geminiOAuthType.value === 'ai_studio') {
if (tierLower === 'aistudio_paid') return 'paid'
if (tierLower === 'aistudio_free') return 'free'
// Backward compatibility
if (tierUpper.includes('PAID') || tierUpper.includes('PAYG') || tierUpper.includes('PAY')) return 'paid'
if (tierUpper.includes('FREE')) return 'free'
if (props.account.type === 'apikey') return 'free'
return null
} }
return null return null
}) })
// Gemini 认证类型(按要求:授权方式简称 + 用户等级)
const geminiAuthTypeLabel = computed(() => {
if (props.account.platform !== 'gemini') return null
if (!geminiChannelShort.value) return null
return geminiUserLevel.value ? `${geminiChannelShort.value} ${geminiUserLevel.value}` : geminiChannelShort.value
})
// Gemini 账户类型徽章样式(统一样式) // Gemini 账户类型徽章样式(统一样式)
const geminiTierClass = computed(() => { const geminiTierClass = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined // Use channel+level to choose a stable color without depending on raw tier_id variants.
const oauthType = creds?.oauth_type const channel = geminiChannelShort.value
const level = geminiUserLevel.value
// Client (自定义 OAuth): 使用蓝色(与 AI Studio 一致) if (channel === 'client' || channel === 'ai studio') {
if (oauthType === 'ai_studio') {
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300' return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
} }
if (!geminiTier.value) return '' if (channel === 'google one') {
if (level === 'ultra') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
const isGoogleOne = creds?.oauth_type === 'google_one' if (level === 'pro') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
if (isGoogleOne) {
// Google One tier 颜色
const colorMap: Record<string, string> = {
AI_PREMIUM: 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300',
GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300',
GOOGLE_ONE_BASIC: 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-300',
FREE: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300'
}
return colorMap[geminiTier.value] || 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
} }
// Code Assist tier 颜色 if (channel === 'gcp') {
switch (geminiTier.value) { if (level === 'enterprise') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
case 'LEGACY': return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
case 'PRO':
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
case 'ULTRA':
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
default:
return ''
} }
return ''
}) })
// Gemini 配额政策信息 // Gemini 配额政策信息
const geminiQuotaPolicyChannel = computed(() => { const geminiQuotaPolicyChannel = computed(() => {
if (geminiOAuthType.value === 'google_one') {
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.channel')
}
if (isGeminiCodeAssist.value) { if (isGeminiCodeAssist.value) {
return t('admin.accounts.gemini.quotaPolicy.rows.cli.channel') return t('admin.accounts.gemini.quotaPolicy.rows.gcp.channel')
} }
return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.channel') return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.channel')
}) })
const geminiQuotaPolicyLimits = computed(() => { const geminiQuotaPolicyLimits = computed(() => {
const tierLower = (geminiTier.value || '').toString().trim().toLowerCase()
if (geminiOAuthType.value === 'google_one') {
if (tierLower === 'google_ai_ultra' || geminiUserLevel.value === 'ultra') {
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.limitsUltra')
}
if (tierLower === 'google_ai_pro' || geminiUserLevel.value === 'pro') {
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.limitsPro')
}
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.limitsFree')
}
if (isGeminiCodeAssist.value) { if (isGeminiCodeAssist.value) {
if (geminiTier.value === 'PRO' || geminiTier.value === 'ULTRA') { if (tierLower === 'gcp_enterprise' || geminiUserLevel.value === 'enterprise') {
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsPremium') return t('admin.accounts.gemini.quotaPolicy.rows.gcp.limitsEnterprise')
} }
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsFree') return t('admin.accounts.gemini.quotaPolicy.rows.gcp.limitsStandard')
}
// AI Studio (API Key / custom OAuth)
if (tierLower === 'aistudio_paid' || geminiUserLevel.value === 'paid') {
return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsPaid')
} }
// AI Studio - 默认显示免费层限制
return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsFree') return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsFree')
}) })
const geminiQuotaPolicyDocsUrl = computed(() => { const geminiQuotaPolicyDocsUrl = computed(() => {
if (isGeminiCodeAssist.value) { if (geminiOAuthType.value === 'google_one' || isGeminiCodeAssist.value) {
return 'https://cloud.google.com/products/gemini/code-assist#pricing' return 'https://developers.google.com/gemini-code-assist/resources/quotas'
} }
return 'https://ai.google.dev/pricing' return 'https://ai.google.dev/pricing'
}) })
const geminiUsesSharedDaily = computed(() => {
if (props.account.platform !== 'gemini') return false
// Per requirement: Google One & GCP are shared RPD pools (no per-model breakdown).
return (
!!usageInfo.value?.gemini_shared_daily ||
!!usageInfo.value?.gemini_shared_minute ||
geminiOAuthType.value === 'google_one' ||
isGeminiCodeAssist.value
)
})
const geminiUsageBars = computed(() => {
if (props.account.platform !== 'gemini') return []
if (!usageInfo.value) return []
const bars: Array<{
key: string
label: string
utilization: number
resetsAt: string | null
windowStats?: WindowStats | null
color: 'indigo' | 'emerald'
}> = []
if (geminiUsesSharedDaily.value) {
const sharedDaily = usageInfo.value.gemini_shared_daily
if (sharedDaily) {
bars.push({
key: 'shared_daily',
label: '1d',
utilization: sharedDaily.utilization,
resetsAt: sharedDaily.resets_at,
windowStats: sharedDaily.window_stats,
color: 'indigo'
})
}
return bars
}
const pro = usageInfo.value.gemini_pro_daily
if (pro) {
bars.push({
key: 'pro_daily',
label: 'pro',
utilization: pro.utilization,
resetsAt: pro.resets_at,
windowStats: pro.window_stats,
color: 'indigo'
})
}
const flash = usageInfo.value.gemini_flash_daily
if (flash) {
bars.push({
key: 'flash_daily',
label: 'flash',
utilization: flash.utilization,
resetsAt: flash.resets_at,
windowStats: flash.window_stats,
color: 'emerald'
})
}
return bars
})
// 账户类型显示标签 // 账户类型显示标签
const antigravityTierLabel = computed(() => { const antigravityTierLabel = computed(() => {
switch (antigravityTier.value) { switch (antigravityTier.value) {
......
...@@ -653,6 +653,41 @@ ...@@ -653,6 +653,41 @@
</div> </div>
</div> </div>
<!-- Tier selection (used as fallback when auto-detection is unavailable/fails) -->
<div class="mt-4">
<label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label>
<div class="mt-2">
<select
v-if="geminiOAuthType === 'google_one'"
v-model="geminiTierGoogleOne"
class="input"
>
<option value="google_one_free">{{ t('admin.accounts.gemini.tier.googleOne.free') }}</option>
<option value="google_ai_pro">{{ t('admin.accounts.gemini.tier.googleOne.pro') }}</option>
<option value="google_ai_ultra">{{ t('admin.accounts.gemini.tier.googleOne.ultra') }}</option>
</select>
<select
v-else-if="geminiOAuthType === 'code_assist'"
v-model="geminiTierGcp"
class="input"
>
<option value="gcp_standard">{{ t('admin.accounts.gemini.tier.gcp.standard') }}</option>
<option value="gcp_enterprise">{{ t('admin.accounts.gemini.tier.gcp.enterprise') }}</option>
</select>
<select
v-else
v-model="geminiTierAIStudio"
class="input"
>
<option value="aistudio_free">{{ t('admin.accounts.gemini.tier.aiStudio.free') }}</option>
<option value="aistudio_paid">{{ t('admin.accounts.gemini.tier.aiStudio.paid') }}</option>
</select>
</div>
<p class="input-hint">{{ t('admin.accounts.gemini.tier.hint') }}</p>
</div>
<div class="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-4 text-xs text-blue-900 dark:border-blue-800/40 dark:bg-blue-900/20 dark:text-blue-200"> <div class="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-4 text-xs text-blue-900 dark:border-blue-800/40 dark:bg-blue-900/20 dark:text-blue-200">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<svg <svg
...@@ -820,6 +855,16 @@ ...@@ -820,6 +855,16 @@
<p class="input-hint">{{ apiKeyHint }}</p> <p class="input-hint">{{ apiKeyHint }}</p>
</div> </div>
<!-- Gemini API Key tier selection -->
<div v-if="form.platform === 'gemini'">
<label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label>
<select v-model="geminiTierAIStudio" class="input">
<option value="aistudio_free">{{ t('admin.accounts.gemini.tier.aiStudio.free') }}</option>
<option value="aistudio_paid">{{ t('admin.accounts.gemini.tier.aiStudio.paid') }}</option>
</select>
<p class="input-hint">{{ t('admin.accounts.gemini.tier.aiStudioHint') }}</p>
</div>
<!-- Model Restriction Section (不适用于 Gemini) --> <!-- Model Restriction Section (不适用于 Gemini) -->
<div v-if="form.platform !== 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div v-if="form.platform !== 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label> <label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
...@@ -1816,6 +1861,24 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_ ...@@ -1816,6 +1861,24 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_
const geminiAIStudioOAuthEnabled = ref(false) const geminiAIStudioOAuthEnabled = ref(false)
const showAdvancedOAuth = ref(false) const showAdvancedOAuth = ref(false)
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free')
const geminiTierGcp = ref<'gcp_standard' | 'gcp_enterprise'>('gcp_standard')
const geminiTierAIStudio = ref<'aistudio_free' | 'aistudio_paid'>('aistudio_free')
const geminiSelectedTier = computed(() => {
if (form.platform !== 'gemini') return ''
if (accountCategory.value === 'apikey') return geminiTierAIStudio.value
switch (geminiOAuthType.value) {
case 'google_one':
return geminiTierGoogleOne.value
case 'code_assist':
return geminiTierGcp.value
default:
return geminiTierAIStudio.value
}
})
const geminiQuotaDocs = { const geminiQuotaDocs = {
codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas', codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas',
aiStudio: 'https://ai.google.dev/pricing', aiStudio: 'https://ai.google.dev/pricing',
...@@ -2143,6 +2206,9 @@ const resetForm = () => { ...@@ -2143,6 +2206,9 @@ const resetForm = () => {
tempUnschedEnabled.value = false tempUnschedEnabled.value = false
tempUnschedRules.value = [] tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
geminiTierGoogleOne.value = 'google_one_free'
geminiTierGcp.value = 'gcp_standard'
geminiTierAIStudio.value = 'aistudio_free'
oauth.resetState() oauth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
geminiOAuth.resetState() geminiOAuth.resetState()
...@@ -2184,6 +2250,9 @@ const handleSubmit = async () => { ...@@ -2184,6 +2250,9 @@ const handleSubmit = async () => {
base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl, base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl,
api_key: apiKeyValue.value.trim() api_key: apiKeyValue.value.trim()
} }
if (form.platform === 'gemini') {
credentials.tier_id = geminiTierAIStudio.value
}
// Add model mapping if configured // Add model mapping if configured
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
...@@ -2237,7 +2306,12 @@ const handleGenerateUrl = async () => { ...@@ -2237,7 +2306,12 @@ const handleGenerateUrl = async () => {
if (form.platform === 'openai') { if (form.platform === 'openai') {
await openaiOAuth.generateAuthUrl(form.proxy_id) await openaiOAuth.generateAuthUrl(form.proxy_id)
} else if (form.platform === 'gemini') { } else if (form.platform === 'gemini') {
await geminiOAuth.generateAuthUrl(form.proxy_id, oauthFlowRef.value?.projectId, geminiOAuthType.value) await geminiOAuth.generateAuthUrl(
form.proxy_id,
oauthFlowRef.value?.projectId,
geminiOAuthType.value,
geminiSelectedTier.value
)
} else if (form.platform === 'antigravity') { } else if (form.platform === 'antigravity') {
await antigravityOAuth.generateAuthUrl(form.proxy_id) await antigravityOAuth.generateAuthUrl(form.proxy_id)
} else { } else {
...@@ -2318,7 +2392,8 @@ const handleGeminiExchange = async (authCode: string) => { ...@@ -2318,7 +2392,8 @@ const handleGeminiExchange = async (authCode: string) => {
sessionId: geminiOAuth.sessionId.value, sessionId: geminiOAuth.sessionId.value,
state: stateToUse, state: stateToUse,
proxyId: form.proxy_id, proxyId: form.proxy_id,
oauthType: geminiOAuthType.value oauthType: geminiOAuthType.value,
tierId: geminiSelectedTier.value
}) })
if (!tokenInfo) return if (!tokenInfo) return
......
...@@ -88,7 +88,35 @@ ...@@ -88,7 +88,35 @@
<!-- Gemini OAuth Type Selection --> <!-- Gemini OAuth Type Selection -->
<fieldset v-if="isGemini" class="border-0 p-0"> <fieldset v-if="isGemini" class="border-0 p-0">
<legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend> <legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend>
<div class="mt-2 grid grid-cols-2 gap-3"> <div class="mt-2 grid grid-cols-3 gap-3">
<button
type="button"
@click="handleSelectGeminiOAuthType('google_one')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'google_one'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">Google One</span>
<span class="text-xs text-gray-500 dark:text-gray-400">个人账号</span>
</div>
</button>
<button <button
type="button" type="button"
@click="handleSelectGeminiOAuthType('code_assist')" @click="handleSelectGeminiOAuthType('code_assist')"
...@@ -305,7 +333,7 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null) ...@@ -305,7 +333,7 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State // State
const addMethod = ref<AddMethod>('oauth') const addMethod = ref<AddMethod>('oauth')
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist') const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist')
const geminiAIStudioOAuthEnabled = ref(false) const geminiAIStudioOAuthEnabled = ref(false)
// Computed - check platform // Computed - check platform
...@@ -367,7 +395,12 @@ watch( ...@@ -367,7 +395,12 @@ watch(
} }
if (isGemini.value) { if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown> const creds = (props.account.credentials || {}) as Record<string, unknown>
geminiOAuthType.value = creds.oauth_type === 'ai_studio' ? 'ai_studio' : 'code_assist' geminiOAuthType.value =
creds.oauth_type === 'google_one'
? 'google_one'
: creds.oauth_type === 'ai_studio'
? 'ai_studio'
: 'code_assist'
} }
if (isGemini.value) { if (isGemini.value) {
geminiOAuth.getCapabilities().then((caps) => { geminiOAuth.getCapabilities().then((caps) => {
...@@ -395,7 +428,7 @@ const resetState = () => { ...@@ -395,7 +428,7 @@ const resetState = () => {
oauthFlowRef.value?.reset() oauthFlowRef.value?.reset()
} }
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'ai_studio') => { const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'google_one' | 'ai_studio') => {
if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) { if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured')) appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
return return
...@@ -413,8 +446,10 @@ const handleGenerateUrl = async () => { ...@@ -413,8 +446,10 @@ const handleGenerateUrl = async () => {
if (isOpenAI.value) { if (isOpenAI.value) {
await openaiOAuth.generateAuthUrl(props.account.proxy_id) await openaiOAuth.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) { } else if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown>
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
const projectId = geminiOAuthType.value === 'code_assist' ? oauthFlowRef.value?.projectId : undefined const projectId = geminiOAuthType.value === 'code_assist' ? oauthFlowRef.value?.projectId : undefined
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value) await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value, tierId)
} else if (isAntigravity.value) { } else if (isAntigravity.value) {
await antigravityOAuth.generateAuthUrl(props.account.proxy_id) await antigravityOAuth.generateAuthUrl(props.account.proxy_id)
} else { } else {
...@@ -475,7 +510,8 @@ const handleExchangeCode = async () => { ...@@ -475,7 +510,8 @@ const handleExchangeCode = async () => {
sessionId, sessionId,
state: stateToUse, state: stateToUse,
proxyId: props.account.proxy_id, proxyId: props.account.proxy_id,
oauthType: geminiOAuthType.value oauthType: geminiOAuthType.value,
tierId: typeof (props.account.credentials as any)?.tier_id === 'string' ? ((props.account.credentials as any).tier_id as string) : undefined
}) })
if (!tokenInfo) return if (!tokenInfo) return
......
...@@ -38,7 +38,8 @@ export function useGeminiOAuth() { ...@@ -38,7 +38,8 @@ export function useGeminiOAuth() {
const generateAuthUrl = async ( const generateAuthUrl = async (
proxyId: number | null | undefined, proxyId: number | null | undefined,
projectId?: string | null, projectId?: string | null,
oauthType?: string oauthType?: string,
tierId?: string
): Promise<boolean> => { ): Promise<boolean> => {
loading.value = true loading.value = true
authUrl.value = '' authUrl.value = ''
...@@ -52,6 +53,8 @@ export function useGeminiOAuth() { ...@@ -52,6 +53,8 @@ export function useGeminiOAuth() {
const trimmedProjectID = projectId?.trim() const trimmedProjectID = projectId?.trim()
if (trimmedProjectID) payload.project_id = trimmedProjectID if (trimmedProjectID) payload.project_id = trimmedProjectID
if (oauthType) payload.oauth_type = oauthType if (oauthType) payload.oauth_type = oauthType
const trimmedTierID = tierId?.trim()
if (trimmedTierID) payload.tier_id = trimmedTierID
const response = await adminAPI.gemini.generateAuthUrl(payload as any) const response = await adminAPI.gemini.generateAuthUrl(payload as any)
authUrl.value = response.auth_url authUrl.value = response.auth_url
...@@ -73,6 +76,7 @@ export function useGeminiOAuth() { ...@@ -73,6 +76,7 @@ export function useGeminiOAuth() {
state: string state: string
proxyId?: number | null proxyId?: number | null
oauthType?: string oauthType?: string
tierId?: string
}): Promise<GeminiTokenInfo | null> => { }): Promise<GeminiTokenInfo | null> => {
const code = params.code?.trim() const code = params.code?.trim()
if (!code || !params.sessionId || !params.state) { if (!code || !params.sessionId || !params.state) {
...@@ -91,6 +95,8 @@ export function useGeminiOAuth() { ...@@ -91,6 +95,8 @@ export function useGeminiOAuth() {
} }
if (params.proxyId) payload.proxy_id = params.proxyId if (params.proxyId) payload.proxy_id = params.proxyId
if (params.oauthType) payload.oauth_type = params.oauthType if (params.oauthType) payload.oauth_type = params.oauthType
const trimmedTierID = params.tierId?.trim()
if (trimmedTierID) payload.tier_id = trimmedTierID
const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any) const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any)
return tokenInfo as GeminiTokenInfo return tokenInfo as GeminiTokenInfo
......
...@@ -1257,6 +1257,25 @@ export default { ...@@ -1257,6 +1257,25 @@ export default {
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
baseUrlHint: 'Leave default for official Gemini API', baseUrlHint: 'Leave default for official Gemini API',
apiKeyHint: 'Your Gemini API Key (starts with AIza)', apiKeyHint: 'Your Gemini API Key (starts with AIza)',
tier: {
label: 'Tier (Quota Level)',
hint: 'Tip: The system will try to auto-detect the tier first; if auto-detection is unavailable or fails, your selected tier is used as a fallback (simulated quota).',
aiStudioHint:
'AI Studio quotas are per-model (Pro/Flash are limited independently). If billing is enabled, choose Pay-as-you-go.',
googleOne: {
free: 'Google One Free (1000 RPD / 60 RPM, shared pool)',
pro: 'Google AI Pro (1500 RPD / 120 RPM, shared pool)',
ultra: 'Google AI Ultra (2000 RPD / 120 RPM, shared pool)'
},
gcp: {
standard: 'GCP Standard (1500 RPD / 120 RPM, shared pool)',
enterprise: 'GCP Enterprise (2000 RPD / 120 RPM, shared pool)'
},
aiStudio: {
free: 'AI Studio Free Tier (Pro: 50 RPD / 2 RPM; Flash: 1500 RPD / 15 RPM)',
paid: 'AI Studio Pay-as-you-go (Pro: ∞ RPD / 1000 RPM; Flash: ∞ RPD / 2000 RPM)'
}
},
accountType: { accountType: {
oauthTitle: 'OAuth (Gemini)', oauthTitle: 'OAuth (Gemini)',
oauthDesc: 'Authorize with your Google account and choose an OAuth type.', oauthDesc: 'Authorize with your Google account and choose an OAuth type.',
...@@ -1317,6 +1336,17 @@ export default { ...@@ -1317,6 +1336,17 @@ export default {
}, },
simulatedNote: 'Simulated quota, for reference only', simulatedNote: 'Simulated quota, for reference only',
rows: { rows: {
googleOne: {
channel: 'Google One OAuth (Individuals / Code Assist for Individuals)',
limitsFree: 'Shared pool: 1000 RPD / 60 RPM',
limitsPro: 'Shared pool: 1500 RPD / 120 RPM',
limitsUltra: 'Shared pool: 2000 RPD / 120 RPM'
},
gcp: {
channel: 'GCP Code Assist OAuth (Enterprise)',
limitsStandard: 'Shared pool: 1500 RPD / 120 RPM',
limitsEnterprise: 'Shared pool: 2000 RPD / 120 RPM'
},
cli: { cli: {
channel: 'Gemini CLI (Official Google Login / Code Assist)', channel: 'Gemini CLI (Official Google Login / Code Assist)',
free: 'Free Google Account', free: 'Free Google Account',
...@@ -1334,7 +1364,7 @@ export default { ...@@ -1334,7 +1364,7 @@ export default {
free: 'No billing (free tier)', free: 'No billing (free tier)',
paid: 'Billing enabled (pay-as-you-go)', paid: 'Billing enabled (pay-as-you-go)',
limitsFree: 'RPD 50; RPM 2 (Pro) / 15 (Flash)', limitsFree: 'RPD 50; RPM 2 (Pro) / 15 (Flash)',
limitsPaid: 'RPD unlimited; RPM 1000+ (per model quota)' limitsPaid: 'RPD unlimited; RPM 1000 (Pro) / 2000 (Flash) (per model)'
}, },
customOAuth: { customOAuth: {
channel: 'Custom OAuth Client (GCP)', channel: 'Custom OAuth Client (GCP)',
......
...@@ -1395,6 +1395,24 @@ export default { ...@@ -1395,6 +1395,24 @@ export default {
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。', modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
baseUrlHint: '留空使用官方 Gemini API', baseUrlHint: '留空使用官方 Gemini API',
apiKeyHint: '您的 Gemini API Key(以 AIza 开头)', apiKeyHint: '您的 Gemini API Key(以 AIza 开头)',
tier: {
label: 'Tier(配额等级)',
hint: '提示:系统会优先尝试自动识别 Tier;若自动识别不可用或失败,则使用你选择的 Tier 作为回退(本地模拟配额)。',
aiStudioHint: 'AI Studio 的配额是按模型分别限流(Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。',
googleOne: {
free: 'Google One Free(1000 RPD / 60 RPM,共享池)',
pro: 'Google AI Pro(1500 RPD / 120 RPM,共享池)',
ultra: 'Google AI Ultra(2000 RPD / 120 RPM,共享池)'
},
gcp: {
standard: 'GCP Standard(1500 RPD / 120 RPM,共享池)',
enterprise: 'GCP Enterprise(2000 RPD / 120 RPM,共享池)'
},
aiStudio: {
free: 'AI Studio Free Tier(Pro: 50 RPD / 2 RPM;Flash: 1500 RPD / 15 RPM)',
paid: 'AI Studio Pay-as-you-go(Pro: ∞ RPD / 1000 RPM;Flash: ∞ RPD / 2000 RPM)'
}
},
accountType: { accountType: {
oauthTitle: 'OAuth 授权(Gemini)', oauthTitle: 'OAuth 授权(Gemini)',
oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。', oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。',
...@@ -1454,6 +1472,17 @@ export default { ...@@ -1454,6 +1472,17 @@ export default {
}, },
simulatedNote: '本地模拟配额,仅供参考', simulatedNote: '本地模拟配额,仅供参考',
rows: { rows: {
googleOne: {
channel: 'Google One OAuth(个人版 / Code Assist for Individuals)',
limitsFree: '共享池:1000 RPD / 60 RPM(不分模型)',
limitsPro: '共享池:1500 RPD / 120 RPM(不分模型)',
limitsUltra: '共享池:2000 RPD / 120 RPM(不分模型)'
},
gcp: {
channel: 'GCP Code Assist OAuth(企业版)',
limitsStandard: '共享池:1500 RPD / 120 RPM(不分模型)',
limitsEnterprise: '共享池:2000 RPD / 120 RPM(不分模型)'
},
cli: { cli: {
channel: 'Gemini CLI(官方 Google 登录 / Code Assist)', channel: 'Gemini CLI(官方 Google 登录 / Code Assist)',
free: '免费 Google 账号', free: '免费 Google 账号',
...@@ -1471,7 +1500,7 @@ export default { ...@@ -1471,7 +1500,7 @@ export default {
free: '未绑卡(免费层)', free: '未绑卡(免费层)',
paid: '已绑卡(按量付费)', paid: '已绑卡(按量付费)',
limitsFree: 'RPD 50;RPM 2(Pro)/ 15(Flash)', limitsFree: 'RPD 50;RPM 2(Pro)/ 15(Flash)',
limitsPaid: 'RPD 不限;RPM 1000+(按模型配额)' limitsPaid: 'RPD 不限;RPM 1000(Pro)/ 2000(Flash)(按模型配额)'
}, },
customOAuth: { customOAuth: {
channel: 'Custom OAuth Client(GCP)', channel: 'Custom OAuth Client(GCP)',
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment