ratelimit_cn_providers.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. package service
  2. import (
  3. "context"
  4. "log/slog"
  5. "net/http"
  6. "strings"
  7. "time"
  8. )
  9. // 国产供应商(kimi/zhipu/deepseek)的响应式冷却辅助。
  10. //
  11. // 与 openai/anthropic 不同:
  12. // - 余额不足是「可恢复」状态(充值/检测恢复后自动重新调度),不能走 handleAuthError
  13. // 永久置 status=error。这里改为 SetTempUnschedulable,由 CN 余额检测周期任务
  14. // (cn_provider_balance_check_service.go)在余额恢复后 ClearTempUnschedulable。
  15. // - Coding Plan 滚动窗口耗尽(429)的冷却终点应是真实的窗口重置时间(已由
  16. // CNProviderQuotaService 落入 account.Extra 快照),而非默认的秒级兜底。
  17. // cnBalanceExtraSuffixLow 标记账号响应过「余额不足」,供余额检测任务区分
  18. // 「确属余额不足」与「尚未探测」。
  19. const cnBalanceExtraSuffixLow = "balance_low"
  20. // cnBalanceLowReasonPrefix 是余额不足临时停调 reason 的稳定前缀。
  21. // 周期余额检测任务据此识别「是我们停调的」并在余额恢复后安全清除——不会误清
  22. // 其他子系统(阈值/限流/401)写入的临时停调。
  23. const cnBalanceLowReasonPrefix = "cn_balance_low"
  24. const kimiConcurrentRequestLimitMessage = "You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again."
  25. const cnConcurrencyLimitReasonPrefix = "cn_concurrency_limit"
  26. func isCNProviderConcurrencyLimit403(account *Account, upstreamMsg string) bool {
  27. return account != nil && account.Platform == PlatformKimi &&
  28. strings.TrimSpace(upstreamMsg) == kimiConcurrentRequestLimitMessage
  29. }
  30. func (s *RateLimitService) handleCNProviderConcurrencyLimit403(
  31. ctx context.Context,
  32. account *Account,
  33. ) {
  34. until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute)
  35. reason := cnConcurrencyLimitReasonPrefix + ": " + kimiConcurrentRequestLimitMessage
  36. s.notifyAccountSchedulingBlocked(account, until, cnConcurrencyLimitReasonPrefix)
  37. if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil {
  38. slog.Warn("cn_concurrency_limit_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
  39. return
  40. }
  41. slog.Info("cn_provider_concurrency_limited",
  42. "account_id", account.ID,
  43. "platform", account.Platform,
  44. "until", until.UTC(),
  45. )
  46. }
  47. // cnBalanceLowReason 构造余额不足临时停调的 reason(带稳定前缀)。
  48. func cnBalanceLowReason(upstreamMsg string) string {
  49. if upstreamMsg = strings.TrimSpace(upstreamMsg); upstreamMsg != "" {
  50. return cnBalanceLowReasonPrefix + ": " + upstreamMsg
  51. }
  52. return cnBalanceLowReasonPrefix + ": 余额不足,账号临时停调"
  53. }
  54. // cnProviderResponseIndicatesInsufficientBalance 通过响应体文案识别余额不足
  55. // (智谱 payg 无独立余额端点,仅能靠响应文案识别)。
  56. func cnProviderResponseIndicatesInsufficientBalance(body []byte) bool {
  57. if len(body) == 0 {
  58. return false
  59. }
  60. s := strings.ToLower(string(body))
  61. return strings.Contains(s, "余额不足") ||
  62. strings.Contains(s, "insufficient balance") ||
  63. strings.Contains(s, "insufficient_credit") ||
  64. strings.Contains(s, "balance is not enough") ||
  65. strings.Contains(s, "no enough balance")
  66. }
  67. // handleCNProviderInsufficientBalance 把余额不足标记为可恢复的临时停调:
  68. // 写入 balance_low 快照 + SetTempUnschedulable 一个余额检测周期,
  69. // 由周期任务在余额恢复后清除。返回前已通知调度阻塞。
  70. func (s *RateLimitService) handleCNProviderInsufficientBalance(
  71. ctx context.Context,
  72. account *Account,
  73. upstreamMsg string,
  74. ) {
  75. msg := cnBalanceLowReason(upstreamMsg)
  76. if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
  77. cnExtraKey(account.Platform, cnBalanceExtraSuffixLow): true,
  78. }); err != nil {
  79. slog.Warn("cn_balance_low_mark_failed", "account_id", account.ID, "error", err)
  80. }
  81. until := time.Now().Add(s.cnBalanceCooldownDuration())
  82. s.notifyAccountSchedulingBlocked(account, until, "cn_insufficient_balance")
  83. if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, msg); err != nil {
  84. slog.Warn("cn_balance_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
  85. return
  86. }
  87. slog.Info("cn_provider_insufficient_balance",
  88. "account_id", account.ID,
  89. "platform", account.Platform,
  90. "until", until.UTC(),
  91. )
  92. }
  93. // cnBalanceCooldownDuration 返回余额不足临时停调的持续时长(= 2× 余额检测周期,
  94. // 默认 20 分钟)。周期任务会在余额恢复后提前清除,故此处只需保证冷却覆盖到下一次
  95. // 周期检测即可。
  96. func (s *RateLimitService) cnBalanceCooldownDuration() time.Duration {
  97. minutes := 10
  98. if s != nil && s.cfg != nil {
  99. if cfgMin := s.cfg.Gateway.CNProviders.BalanceCheckIntervalMinutes; cfgMin > 0 {
  100. minutes = cfgMin
  101. }
  102. }
  103. cooldown := time.Duration(minutes) * time.Minute * 2
  104. if cooldown < time.Minute {
  105. cooldown = 10 * time.Minute
  106. }
  107. return cooldown
  108. }
  109. // cnProviderQuotaSnapshotReset 读取 Coding Plan 账号快照中最早一个仍在未来的窗口
  110. // 重置时间(5h / weekly)。429 多数由 5h 滚动窗口触发,取较早的重置点可避免
  111. // 把账号冷却到 weekly 重置(可达数天)的过度停调;如果确是 weekly 窗口耗尽,
  112. // 周期额度探测刷新快照后阈值评估会再次停调到正确的时间点。
  113. // 无快照或均已过期返回 nil。
  114. func cnProviderQuotaSnapshotReset(account *Account, now time.Time) *time.Time {
  115. if account == nil || !account.IsCNProvider() || !account.IsCodingPlan() || len(account.Extra) == 0 {
  116. return nil
  117. }
  118. provider := account.Platform
  119. var earliest *time.Time
  120. for _, suffix := range []string{cnExtraSuffix5hReset, cnExtraSuffixWeeklyReset} {
  121. t := parseSchedulingResetAt(account.Extra[cnExtraKey(provider, suffix)])
  122. if t == nil || !t.After(now) {
  123. continue
  124. }
  125. if earliest == nil || t.Before(*earliest) {
  126. earliest = t
  127. }
  128. }
  129. return earliest
  130. }
  131. // applyCNProviderReactive429 处理国产供应商的 429 响应。
  132. // 返回 true 表示已处理(调用方应 return),false 表示未命中、继续走默认 429 逻辑。
  133. func (s *RateLimitService) applyCNProviderReactive429(
  134. ctx context.Context,
  135. account *Account,
  136. headers http.Header,
  137. responseBody []byte,
  138. ) bool {
  139. if !account.IsCNProvider() {
  140. return false
  141. }
  142. // 1) 余额不足文案:可恢复临时停调(含智谱 payg 这类无余额端点的场景)。
  143. if cnProviderResponseIndicatesInsufficientBalance(responseBody) {
  144. s.handleCNProviderInsufficientBalance(ctx, account, extractUpstreamErrorMessage(responseBody))
  145. return true
  146. }
  147. // 2) Coding Plan 窗口耗尽:冷却到快照中最早的窗口重置点(见
  148. // cnProviderQuotaSnapshotReset:429 多由 5h 窗口触发,取较早点避免过度停调)。
  149. if account.IsCodingPlan() {
  150. if until := cnProviderQuotaSnapshotReset(account, time.Now()); until != nil {
  151. s.notifyAccountSchedulingBlocked(account, *until, "429")
  152. if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
  153. slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
  154. return true
  155. }
  156. slog.Info("cn_coding_plan_rate_limited",
  157. "account_id", account.ID,
  158. "platform", account.Platform,
  159. "reset_at", *until,
  160. )
  161. return true
  162. }
  163. }
  164. return false
  165. }