gemini_quota.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "log"
  7. "strings"
  8. "sync"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
  12. )
  13. type geminiModelClass string
  14. const (
  15. geminiModelPro geminiModelClass = "pro"
  16. geminiModelFlash geminiModelClass = "flash"
  17. )
  18. type GeminiQuota struct {
  19. // SharedRPD is a shared requests-per-day pool across models.
  20. // When SharedRPD > 0, callers should treat ProRPD/FlashRPD as not applicable for daily quota checks.
  21. SharedRPD int64 `json:"shared_rpd,omitempty"`
  22. // SharedRPM is a shared requests-per-minute pool across models.
  23. // When SharedRPM > 0, callers should treat ProRPM/FlashRPM as not applicable for minute quota checks.
  24. SharedRPM int64 `json:"shared_rpm,omitempty"`
  25. // Per-model quotas (AI Studio / API key).
  26. // A value of -1 means "unlimited" (pay-as-you-go).
  27. ProRPD int64 `json:"pro_rpd,omitempty"`
  28. ProRPM int64 `json:"pro_rpm,omitempty"`
  29. FlashRPD int64 `json:"flash_rpd,omitempty"`
  30. FlashRPM int64 `json:"flash_rpm,omitempty"`
  31. }
  32. type GeminiTierPolicy struct {
  33. Quota GeminiQuota
  34. Cooldown time.Duration
  35. }
  36. type GeminiQuotaPolicy struct {
  37. tiers map[string]GeminiTierPolicy
  38. }
  39. type GeminiUsageTotals struct {
  40. ProRequests int64
  41. FlashRequests int64
  42. ProTokens int64
  43. FlashTokens int64
  44. ProCost float64
  45. FlashCost float64
  46. }
  47. const geminiQuotaCacheTTL = time.Minute
  48. type geminiQuotaOverridesV1 struct {
  49. Tiers map[string]config.GeminiTierQuotaConfig `json:"tiers"`
  50. }
  51. type geminiQuotaOverridesV2 struct {
  52. QuotaRules map[string]geminiQuotaRuleOverride `json:"quota_rules"`
  53. }
  54. type geminiQuotaRuleOverride struct {
  55. SharedRPD *int64 `json:"shared_rpd,omitempty"`
  56. SharedRPM *int64 `json:"rpm,omitempty"`
  57. GeminiPro *geminiModelQuotaOverride `json:"gemini_pro,omitempty"`
  58. GeminiFlash *geminiModelQuotaOverride `json:"gemini_flash,omitempty"`
  59. Desc *string `json:"desc,omitempty"`
  60. }
  61. type geminiModelQuotaOverride struct {
  62. RPD *int64 `json:"rpd,omitempty"`
  63. RPM *int64 `json:"rpm,omitempty"`
  64. }
  65. type GeminiQuotaService struct {
  66. cfg *config.Config
  67. settingRepo SettingRepository
  68. mu sync.Mutex
  69. cachedAt time.Time
  70. policy *GeminiQuotaPolicy
  71. }
  72. func NewGeminiQuotaService(cfg *config.Config, settingRepo SettingRepository) *GeminiQuotaService {
  73. return &GeminiQuotaService{
  74. cfg: cfg,
  75. settingRepo: settingRepo,
  76. }
  77. }
  78. func (s *GeminiQuotaService) Policy(ctx context.Context) *GeminiQuotaPolicy {
  79. if s == nil {
  80. return newGeminiQuotaPolicy()
  81. }
  82. now := time.Now()
  83. s.mu.Lock()
  84. if s.policy != nil && now.Sub(s.cachedAt) < geminiQuotaCacheTTL {
  85. policy := s.policy
  86. s.mu.Unlock()
  87. return policy
  88. }
  89. s.mu.Unlock()
  90. policy := newGeminiQuotaPolicy()
  91. if s.cfg != nil {
  92. policy.ApplyOverrides(s.cfg.Gemini.Quota.Tiers)
  93. if strings.TrimSpace(s.cfg.Gemini.Quota.Policy) != "" {
  94. raw := []byte(s.cfg.Gemini.Quota.Policy)
  95. var overridesV2 geminiQuotaOverridesV2
  96. if err := json.Unmarshal(raw, &overridesV2); err == nil && len(overridesV2.QuotaRules) > 0 {
  97. policy.ApplyQuotaRulesOverrides(overridesV2.QuotaRules)
  98. } else {
  99. var overridesV1 geminiQuotaOverridesV1
  100. if err := json.Unmarshal(raw, &overridesV1); err != nil {
  101. log.Printf("gemini quota: parse config policy failed: %v", err)
  102. } else {
  103. policy.ApplyOverrides(overridesV1.Tiers)
  104. }
  105. }
  106. }
  107. }
  108. if s.settingRepo != nil {
  109. value, err := s.settingRepo.GetValue(ctx, SettingKeyGeminiQuotaPolicy)
  110. if err != nil && !errors.Is(err, ErrSettingNotFound) {
  111. log.Printf("gemini quota: load setting failed: %v", err)
  112. } else if strings.TrimSpace(value) != "" {
  113. raw := []byte(value)
  114. var overridesV2 geminiQuotaOverridesV2
  115. if err := json.Unmarshal(raw, &overridesV2); err == nil && len(overridesV2.QuotaRules) > 0 {
  116. policy.ApplyQuotaRulesOverrides(overridesV2.QuotaRules)
  117. } else {
  118. var overridesV1 geminiQuotaOverridesV1
  119. if err := json.Unmarshal(raw, &overridesV1); err != nil {
  120. log.Printf("gemini quota: parse setting failed: %v", err)
  121. } else {
  122. policy.ApplyOverrides(overridesV1.Tiers)
  123. }
  124. }
  125. }
  126. }
  127. s.mu.Lock()
  128. s.policy = policy
  129. s.cachedAt = now
  130. s.mu.Unlock()
  131. return policy
  132. }
  133. func (s *GeminiQuotaService) QuotaForAccount(ctx context.Context, account *Account) (GeminiQuota, bool) {
  134. if account == nil || account.Platform != PlatformGemini {
  135. return GeminiQuota{}, false
  136. }
  137. // Map (oauth_type + tier_id) to a canonical policy tier key.
  138. // This keeps the policy table stable even if upstream tier_id strings vary.
  139. tierKey := geminiQuotaTierKeyForAccount(account)
  140. if tierKey == "" {
  141. return GeminiQuota{}, false
  142. }
  143. policy := s.Policy(ctx)
  144. return policy.QuotaForTier(tierKey)
  145. }
  146. func (s *GeminiQuotaService) CooldownForTier(ctx context.Context, tierID string) time.Duration {
  147. policy := s.Policy(ctx)
  148. return policy.CooldownForTier(tierID)
  149. }
  150. func (s *GeminiQuotaService) CooldownForAccount(ctx context.Context, account *Account) time.Duration {
  151. if s == nil || account == nil || account.Platform != PlatformGemini {
  152. return 5 * time.Minute
  153. }
  154. tierKey := geminiQuotaTierKeyForAccount(account)
  155. if strings.TrimSpace(tierKey) == "" {
  156. return 5 * time.Minute
  157. }
  158. return s.CooldownForTier(ctx, tierKey)
  159. }
  160. func newGeminiQuotaPolicy() *GeminiQuotaPolicy {
  161. return &GeminiQuotaPolicy{
  162. tiers: map[string]GeminiTierPolicy{
  163. // --- AI Studio / API Key (per-model) ---
  164. // aistudio_free:
  165. // - gemini_pro: 50 RPD / 2 RPM
  166. // - gemini_flash: 1500 RPD / 15 RPM
  167. GeminiTierAIStudioFree: {Quota: GeminiQuota{ProRPD: 50, ProRPM: 2, FlashRPD: 1500, FlashRPM: 15}, Cooldown: 30 * time.Minute},
  168. // aistudio_paid: -1 means "unlimited/pay-as-you-go" for RPD.
  169. GeminiTierAIStudioPaid: {Quota: GeminiQuota{ProRPD: -1, ProRPM: 1000, FlashRPD: -1, FlashRPM: 2000}, Cooldown: 5 * time.Minute},
  170. // --- Google One (shared pool) ---
  171. GeminiTierGoogleOneFree: {Quota: GeminiQuota{SharedRPD: 1000, SharedRPM: 60}, Cooldown: 30 * time.Minute},
  172. GeminiTierGoogleAIPro: {Quota: GeminiQuota{SharedRPD: 1500, SharedRPM: 120}, Cooldown: 5 * time.Minute},
  173. GeminiTierGoogleAIUltra: {Quota: GeminiQuota{SharedRPD: 2000, SharedRPM: 120}, Cooldown: 5 * time.Minute},
  174. // --- GCP Code Assist (shared pool) ---
  175. GeminiTierGCPStandard: {Quota: GeminiQuota{SharedRPD: 1500, SharedRPM: 120}, Cooldown: 5 * time.Minute},
  176. GeminiTierGCPEnterprise: {Quota: GeminiQuota{SharedRPD: 2000, SharedRPM: 120}, Cooldown: 5 * time.Minute},
  177. },
  178. }
  179. }
  180. func (p *GeminiQuotaPolicy) ApplyOverrides(tiers map[string]config.GeminiTierQuotaConfig) {
  181. if p == nil || len(tiers) == 0 {
  182. return
  183. }
  184. for rawID, override := range tiers {
  185. tierID := normalizeGeminiTierID(rawID)
  186. if tierID == "" {
  187. continue
  188. }
  189. policy, ok := p.tiers[tierID]
  190. if !ok {
  191. policy = GeminiTierPolicy{Cooldown: 5 * time.Minute}
  192. }
  193. // Backward-compatible overrides:
  194. // - If the tier uses shared quota, interpret pro_rpd as shared_rpd.
  195. // - Otherwise apply per-model overrides.
  196. if override.ProRPD != nil {
  197. if policy.Quota.SharedRPD > 0 {
  198. policy.Quota.SharedRPD = clampGeminiQuotaInt64WithUnlimited(*override.ProRPD)
  199. } else {
  200. policy.Quota.ProRPD = clampGeminiQuotaInt64WithUnlimited(*override.ProRPD)
  201. }
  202. }
  203. if override.FlashRPD != nil {
  204. if policy.Quota.SharedRPD > 0 {
  205. // No separate flash RPD for shared tiers.
  206. } else {
  207. policy.Quota.FlashRPD = clampGeminiQuotaInt64WithUnlimited(*override.FlashRPD)
  208. }
  209. }
  210. if override.CooldownMinutes != nil {
  211. minutes := clampGeminiQuotaInt(*override.CooldownMinutes)
  212. policy.Cooldown = time.Duration(minutes) * time.Minute
  213. }
  214. p.tiers[tierID] = policy
  215. }
  216. }
  217. func (p *GeminiQuotaPolicy) ApplyQuotaRulesOverrides(rules map[string]geminiQuotaRuleOverride) {
  218. if p == nil || len(rules) == 0 {
  219. return
  220. }
  221. for rawID, override := range rules {
  222. tierID := normalizeGeminiTierID(rawID)
  223. if tierID == "" {
  224. continue
  225. }
  226. policy, ok := p.tiers[tierID]
  227. if !ok {
  228. policy = GeminiTierPolicy{Cooldown: 5 * time.Minute}
  229. }
  230. if override.SharedRPD != nil {
  231. policy.Quota.SharedRPD = clampGeminiQuotaInt64WithUnlimited(*override.SharedRPD)
  232. }
  233. if override.SharedRPM != nil {
  234. policy.Quota.SharedRPM = clampGeminiQuotaRPM(*override.SharedRPM)
  235. }
  236. if override.GeminiPro != nil {
  237. if override.GeminiPro.RPD != nil {
  238. policy.Quota.ProRPD = clampGeminiQuotaInt64WithUnlimited(*override.GeminiPro.RPD)
  239. }
  240. if override.GeminiPro.RPM != nil {
  241. policy.Quota.ProRPM = clampGeminiQuotaRPM(*override.GeminiPro.RPM)
  242. }
  243. }
  244. if override.GeminiFlash != nil {
  245. if override.GeminiFlash.RPD != nil {
  246. policy.Quota.FlashRPD = clampGeminiQuotaInt64WithUnlimited(*override.GeminiFlash.RPD)
  247. }
  248. if override.GeminiFlash.RPM != nil {
  249. policy.Quota.FlashRPM = clampGeminiQuotaRPM(*override.GeminiFlash.RPM)
  250. }
  251. }
  252. p.tiers[tierID] = policy
  253. }
  254. }
  255. func (p *GeminiQuotaPolicy) QuotaForTier(tierID string) (GeminiQuota, bool) {
  256. policy, ok := p.policyForTier(tierID)
  257. if !ok {
  258. return GeminiQuota{}, false
  259. }
  260. return policy.Quota, true
  261. }
  262. func (p *GeminiQuotaPolicy) CooldownForTier(tierID string) time.Duration {
  263. policy, ok := p.policyForTier(tierID)
  264. if ok && policy.Cooldown > 0 {
  265. return policy.Cooldown
  266. }
  267. return 5 * time.Minute
  268. }
  269. func (p *GeminiQuotaPolicy) policyForTier(tierID string) (GeminiTierPolicy, bool) {
  270. if p == nil {
  271. return GeminiTierPolicy{}, false
  272. }
  273. normalized := normalizeGeminiTierID(tierID)
  274. if policy, ok := p.tiers[normalized]; ok {
  275. return policy, true
  276. }
  277. return GeminiTierPolicy{}, false
  278. }
  279. func normalizeGeminiTierID(tierID string) string {
  280. tierID = strings.TrimSpace(tierID)
  281. if tierID == "" {
  282. return ""
  283. }
  284. // Prefer canonical mapping (handles legacy tier strings).
  285. if canonical := canonicalGeminiTierID(tierID); canonical != "" {
  286. return canonical
  287. }
  288. // Accept older policy keys that used uppercase names.
  289. switch strings.ToUpper(tierID) {
  290. case "AISTUDIO_FREE":
  291. return GeminiTierAIStudioFree
  292. case "AISTUDIO_PAID":
  293. return GeminiTierAIStudioPaid
  294. case "GOOGLE_ONE_FREE":
  295. return GeminiTierGoogleOneFree
  296. case "GOOGLE_AI_PRO":
  297. return GeminiTierGoogleAIPro
  298. case "GOOGLE_AI_ULTRA":
  299. return GeminiTierGoogleAIUltra
  300. case "GCP_STANDARD":
  301. return GeminiTierGCPStandard
  302. case "GCP_ENTERPRISE":
  303. return GeminiTierGCPEnterprise
  304. }
  305. return strings.ToLower(tierID)
  306. }
  307. func clampGeminiQuotaInt64WithUnlimited(value int64) int64 {
  308. if value < -1 {
  309. return 0
  310. }
  311. return value
  312. }
  313. func clampGeminiQuotaInt(value int) int {
  314. if value < 0 {
  315. return 0
  316. }
  317. return value
  318. }
  319. func clampGeminiQuotaRPM(value int64) int64 {
  320. if value < 0 {
  321. return 0
  322. }
  323. return value
  324. }
  325. func geminiCooldownForTier(tierID string) time.Duration {
  326. policy := newGeminiQuotaPolicy()
  327. return policy.CooldownForTier(tierID)
  328. }
  329. func geminiQuotaTierKeyForAccount(account *Account) string {
  330. if account == nil || account.Platform != PlatformGemini {
  331. return ""
  332. }
  333. // Note: GeminiOAuthType() already defaults legacy (project_id present) to code_assist.
  334. oauthType := strings.ToLower(strings.TrimSpace(account.GeminiOAuthType()))
  335. rawTier := strings.TrimSpace(account.GeminiTierID())
  336. // Prefer the canonical tier stored in credentials.
  337. if tierID := canonicalGeminiTierIDForOAuthType(oauthType, rawTier); tierID != "" && tierID != GeminiTierGoogleOneUnknown {
  338. return tierID
  339. }
  340. // Fallback defaults when tier_id is missing or unknown.
  341. switch oauthType {
  342. case "google_one":
  343. return GeminiTierGoogleOneFree
  344. case "code_assist":
  345. return GeminiTierGCPStandard
  346. case "ai_studio":
  347. return GeminiTierAIStudioFree
  348. default:
  349. // API Key accounts (type=apikey) have empty oauth_type and are treated as AI Studio.
  350. return GeminiTierAIStudioFree
  351. }
  352. }
  353. func geminiModelClassFromName(model string) geminiModelClass {
  354. name := strings.ToLower(strings.TrimSpace(model))
  355. if strings.Contains(name, "flash") || strings.Contains(name, "lite") {
  356. return geminiModelFlash
  357. }
  358. return geminiModelPro
  359. }
  360. func geminiAggregateUsage(stats []usagestats.ModelStat) GeminiUsageTotals {
  361. var totals GeminiUsageTotals
  362. for _, stat := range stats {
  363. switch geminiModelClassFromName(stat.Model) {
  364. case geminiModelFlash:
  365. totals.FlashRequests += stat.Requests
  366. totals.FlashTokens += stat.TotalTokens
  367. totals.FlashCost += stat.ActualCost
  368. default:
  369. totals.ProRequests += stat.Requests
  370. totals.ProTokens += stat.TotalTokens
  371. totals.ProCost += stat.ActualCost
  372. }
  373. }
  374. return totals
  375. }
  376. func geminiQuotaLocation() *time.Location {
  377. loc, err := time.LoadLocation("America/Los_Angeles")
  378. if err != nil {
  379. return time.FixedZone("PST", -8*3600)
  380. }
  381. return loc
  382. }
  383. func geminiDailyWindowStart(now time.Time) time.Time {
  384. loc := geminiQuotaLocation()
  385. localNow := now.In(loc)
  386. return time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc)
  387. }
  388. func geminiDailyResetTime(now time.Time) time.Time {
  389. loc := geminiQuotaLocation()
  390. localNow := now.In(loc)
  391. start := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc)
  392. reset := start.Add(24 * time.Hour)
  393. if !reset.After(localNow) {
  394. reset = reset.Add(24 * time.Hour)
  395. }
  396. return reset
  397. }