antigravity_quota_fetcher.go 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "regexp"
  9. "strings"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
  13. )
  14. const (
  15. forbiddenTypeValidation = "validation"
  16. forbiddenTypeViolation = "violation"
  17. forbiddenTypeForbidden = "forbidden"
  18. // 机器可读的错误码
  19. errorCodeForbidden = "forbidden"
  20. errorCodeUnauthenticated = "unauthenticated"
  21. errorCodeRateLimited = "rate_limited"
  22. errorCodeNetworkError = "network_error"
  23. )
  24. // AntigravityQuotaFetcher 从 Antigravity API 获取额度
  25. type AntigravityQuotaFetcher struct {
  26. proxyRepo ProxyRepository
  27. cfg *config.Config
  28. }
  29. // NewAntigravityQuotaFetcher 创建 AntigravityQuotaFetcher
  30. func NewAntigravityQuotaFetcher(proxyRepo ProxyRepository, cfg *config.Config) *AntigravityQuotaFetcher {
  31. return &AntigravityQuotaFetcher{proxyRepo: proxyRepo, cfg: cfg}
  32. }
  33. // CanFetch 检查是否可以获取此账户的额度
  34. func (f *AntigravityQuotaFetcher) CanFetch(account *Account) bool {
  35. if account.Platform != PlatformAntigravity {
  36. return false
  37. }
  38. accessToken := account.GetCredential("access_token")
  39. return accessToken != ""
  40. }
  41. // FetchQuota 获取 Antigravity 账户额度信息
  42. func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Account, proxyURL string) (*QuotaResult, error) {
  43. accessToken := account.GetCredential("access_token")
  44. projectID := account.GetCredential("project_id")
  45. client, err := antigravity.NewClient(proxyURL)
  46. if err != nil {
  47. return nil, fmt.Errorf("create antigravity client failed: %w", err)
  48. }
  49. // 调用 API 获取配额
  50. modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID, resolveModelsListReadLimit(f.cfg))
  51. if err != nil {
  52. // 403 Forbidden: 不报错,返回 is_forbidden 标记
  53. var forbiddenErr *antigravity.ForbiddenError
  54. if errors.As(err, &forbiddenErr) {
  55. now := time.Now()
  56. fbType := classifyForbiddenType(forbiddenErr.Body)
  57. return &QuotaResult{
  58. UsageInfo: &UsageInfo{
  59. UpdatedAt: &now,
  60. IsForbidden: true,
  61. ForbiddenReason: forbiddenErr.Body,
  62. ForbiddenType: fbType,
  63. ValidationURL: extractValidationURL(forbiddenErr.Body),
  64. NeedsVerify: fbType == forbiddenTypeValidation,
  65. IsBanned: fbType == forbiddenTypeViolation,
  66. ErrorCode: errorCodeForbidden,
  67. },
  68. }, nil
  69. }
  70. return nil, err
  71. }
  72. // 调用 LoadCodeAssist 获取订阅等级和 AI Credits 余额(非关键路径,失败不影响主流程)
  73. tierRaw, tierNormalized, loadResp := f.fetchSubscriptionTier(ctx, client, accessToken)
  74. // 转换为 UsageInfo
  75. usageInfo := f.buildUsageInfo(modelsResp, tierRaw, tierNormalized, loadResp)
  76. return &QuotaResult{
  77. UsageInfo: usageInfo,
  78. Raw: modelsRaw,
  79. }, nil
  80. }
  81. // fetchSubscriptionTier 获取账号订阅等级,失败返回空字符串。
  82. // 同时返回 LoadCodeAssistResponse,以便提取 AI Credits 余额。
  83. func (f *AntigravityQuotaFetcher) fetchSubscriptionTier(ctx context.Context, client *antigravity.Client, accessToken string) (raw, normalized string, loadResp *antigravity.LoadCodeAssistResponse) {
  84. loadResp, _, err := client.LoadCodeAssist(ctx, accessToken)
  85. if err != nil {
  86. slog.Warn("failed to fetch subscription tier", "error", err)
  87. return "", "", nil
  88. }
  89. if loadResp == nil {
  90. return "", "", nil
  91. }
  92. raw = loadResp.GetTier() // 已有方法:paidTier > currentTier
  93. normalized = normalizeTier(raw)
  94. return raw, normalized, loadResp
  95. }
  96. // normalizeTier 将原始 tier 字符串归一化为 FREE/PRO/ULTRA/UNKNOWN
  97. func normalizeTier(raw string) string {
  98. if raw == "" {
  99. return ""
  100. }
  101. lower := strings.ToLower(raw)
  102. switch {
  103. case strings.Contains(lower, "ultra"):
  104. return "ULTRA"
  105. case strings.Contains(lower, "pro"):
  106. return "PRO"
  107. case strings.Contains(lower, "free"):
  108. return "FREE"
  109. default:
  110. return "UNKNOWN"
  111. }
  112. }
  113. // buildUsageInfo 将 API 响应转换为 UsageInfo。
  114. func (f *AntigravityQuotaFetcher) buildUsageInfo(modelsResp *antigravity.FetchAvailableModelsResponse, tierRaw, tierNormalized string, loadResp *antigravity.LoadCodeAssistResponse) *UsageInfo {
  115. now := time.Now()
  116. info := &UsageInfo{
  117. UpdatedAt: &now,
  118. AntigravityQuota: make(map[string]*AntigravityModelQuota),
  119. AntigravityQuotaDetails: make(map[string]*AntigravityModelDetail),
  120. SubscriptionTier: tierNormalized,
  121. SubscriptionTierRaw: tierRaw,
  122. }
  123. // 遍历所有模型,填充 AntigravityQuota 和 AntigravityQuotaDetails
  124. for modelName, modelInfo := range modelsResp.Models {
  125. if modelInfo.QuotaInfo == nil {
  126. continue
  127. }
  128. // remainingFraction 是剩余比例 (0.0-1.0),转换为使用率百分比
  129. utilization := int((1.0 - modelInfo.QuotaInfo.RemainingFraction) * 100)
  130. info.AntigravityQuota[modelName] = &AntigravityModelQuota{
  131. Utilization: utilization,
  132. ResetTime: modelInfo.QuotaInfo.ResetTime,
  133. }
  134. // 填充模型详细能力信息
  135. detail := &AntigravityModelDetail{
  136. DisplayName: modelInfo.DisplayName,
  137. SupportsImages: modelInfo.SupportsImages,
  138. SupportsThinking: modelInfo.SupportsThinking,
  139. ThinkingBudget: modelInfo.ThinkingBudget,
  140. Recommended: modelInfo.Recommended,
  141. MaxTokens: modelInfo.MaxTokens,
  142. MaxOutputTokens: modelInfo.MaxOutputTokens,
  143. SupportedMimeTypes: modelInfo.SupportedMimeTypes,
  144. }
  145. info.AntigravityQuotaDetails[modelName] = detail
  146. }
  147. // 废弃模型转发规则
  148. if len(modelsResp.DeprecatedModelIDs) > 0 {
  149. info.ModelForwardingRules = make(map[string]string, len(modelsResp.DeprecatedModelIDs))
  150. for oldID, deprecated := range modelsResp.DeprecatedModelIDs {
  151. info.ModelForwardingRules[oldID] = deprecated.NewModelID
  152. }
  153. }
  154. // 同时设置 FiveHour 用于兼容展示(取主要模型)
  155. priorityModels := []string{"claude-sonnet-4-20250514", "claude-sonnet-4", "gemini-2.5-pro"}
  156. for _, modelName := range priorityModels {
  157. if modelInfo, ok := modelsResp.Models[modelName]; ok && modelInfo.QuotaInfo != nil {
  158. utilization := (1.0 - modelInfo.QuotaInfo.RemainingFraction) * 100
  159. progress := &UsageProgress{
  160. Utilization: utilization,
  161. }
  162. if modelInfo.QuotaInfo.ResetTime != "" {
  163. if resetTime, err := time.Parse(time.RFC3339, modelInfo.QuotaInfo.ResetTime); err == nil {
  164. progress.ResetsAt = &resetTime
  165. progress.RemainingSeconds = int(time.Until(resetTime).Seconds())
  166. }
  167. }
  168. info.FiveHour = progress
  169. break
  170. }
  171. }
  172. if loadResp != nil {
  173. for _, credit := range loadResp.GetAvailableCredits() {
  174. info.AICredits = append(info.AICredits, AICredit{
  175. CreditType: credit.CreditType,
  176. Amount: credit.GetAmount(),
  177. MinimumBalance: credit.GetMinimumAmount(),
  178. })
  179. }
  180. }
  181. return info
  182. }
  183. // GetProxyURL 获取账户的代理 URL
  184. func (f *AntigravityQuotaFetcher) GetProxyURL(ctx context.Context, account *Account) string {
  185. if account.ProxyID == nil || f.proxyRepo == nil {
  186. return ""
  187. }
  188. proxy, err := f.proxyRepo.GetByID(ctx, *account.ProxyID)
  189. if err != nil || proxy == nil {
  190. return ""
  191. }
  192. return proxy.URL()
  193. }
  194. // classifyForbiddenType 根据 403 响应体判断禁止类型
  195. func classifyForbiddenType(body string) string {
  196. lower := strings.ToLower(body)
  197. switch {
  198. case strings.Contains(lower, "validation_required") ||
  199. strings.Contains(lower, "verify your account") ||
  200. strings.Contains(lower, "validation_url"):
  201. return forbiddenTypeValidation
  202. case strings.Contains(lower, "terms of service") ||
  203. strings.Contains(lower, "violation"):
  204. return forbiddenTypeViolation
  205. default:
  206. return forbiddenTypeForbidden
  207. }
  208. }
  209. // urlPattern 用于从 403 响应体中提取 URL(降级方案)
  210. var urlPattern = regexp.MustCompile(`https://[^\s"'\\]+`)
  211. // extractValidationURL 从 403 响应 JSON 中提取验证/申诉链接
  212. func extractValidationURL(body string) string {
  213. // 1. 尝试结构化 JSON 提取: /error/details[*]/metadata/validation_url 或 appeal_url
  214. var parsed struct {
  215. Error struct {
  216. Details []struct {
  217. Metadata map[string]string `json:"metadata"`
  218. } `json:"details"`
  219. } `json:"error"`
  220. }
  221. if json.Unmarshal([]byte(body), &parsed) == nil {
  222. for _, detail := range parsed.Error.Details {
  223. if u := detail.Metadata["validation_url"]; u != "" {
  224. return u
  225. }
  226. if u := detail.Metadata["appeal_url"]; u != "" {
  227. return u
  228. }
  229. }
  230. }
  231. // 2. 降级:正则匹配 URL
  232. lower := strings.ToLower(body)
  233. if !strings.Contains(lower, "validation") &&
  234. !strings.Contains(lower, "verify") &&
  235. !strings.Contains(lower, "appeal") {
  236. return ""
  237. }
  238. // 先解码常见转义再匹配
  239. normalized := strings.ReplaceAll(body, `\u0026`, "&")
  240. if m := urlPattern.FindString(normalized); m != "" {
  241. return m
  242. }
  243. return ""
  244. }