rotate_probe.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. """Quota probing helpers for rotate."""
  2. from __future__ import annotations
  3. from concurrent.futures import ThreadPoolExecutor, as_completed
  4. import json
  5. import re
  6. from platforms.chatgpt.fingerprint import build_browser_headers
  7. from .common import cpa_management_request
  8. P401 = re.compile(
  9. r"(^|\D)401(\D|$)|unauthorized|unauthenticated|"
  10. r"token\s+expired|authentication\s+token\s+is\s+expired|login\s+required|"
  11. r"authentication\s+failed|token.+invalidated|token_invalidated",
  12. re.I,
  13. )
  14. P429 = re.compile(r"(^|\D)429(\D|$)|usage_limit_reached|rate_limit_exceeded", re.I)
  15. P_DEACTIVATED = re.compile(r"account[_\s-]*deactivated|has been deactivated", re.I)
  16. QUOTA_VALIDATE_URL = "https://chatgpt.com/backend-api/wham/usage"
  17. def _compact_text(value: str, limit: int = 240) -> str:
  18. return " ".join(str(value or "").split())[:limit]
  19. def classify_status_message(status_message: str) -> int:
  20. raw = str(status_message or "").strip()
  21. if not raw:
  22. return 200
  23. if P401.search(raw) or P_DEACTIVATED.search(raw):
  24. return 401
  25. if P429.search(raw):
  26. return 429
  27. try:
  28. payload = json.loads(raw)
  29. except json.JSONDecodeError:
  30. return 0
  31. if not isinstance(payload, dict):
  32. return 0
  33. err = payload.get("error")
  34. if not isinstance(err, dict):
  35. return 0
  36. err_type = str(err.get("type") or "").strip().lower()
  37. err_code = str(err.get("code") or "").strip().lower()
  38. err_message = str(err.get("message") or "").strip()
  39. if (
  40. err_type in {"unauthorized", "invalidated", "account_deactivated"}
  41. or err_code in {"token_invalidated", "account_deactivated"}
  42. or P401.search(err_message)
  43. or P_DEACTIVATED.search(err_message)
  44. ):
  45. return 401
  46. if err_type in {"usage_limit_reached", "rate_limit_exceeded"} or P429.search(err_message):
  47. return 429
  48. return 0
  49. def is_deactivated_status_message(status_message: str) -> bool:
  50. raw = str(status_message or "").strip()
  51. if not raw:
  52. return False
  53. if P_DEACTIVATED.search(raw):
  54. return True
  55. try:
  56. payload = json.loads(raw)
  57. except json.JSONDecodeError:
  58. return False
  59. if not isinstance(payload, dict):
  60. return False
  61. err = payload.get("error")
  62. if not isinstance(err, dict):
  63. return False
  64. err_type = str(err.get("type") or "").strip().lower()
  65. err_code = str(err.get("code") or "").strip().lower()
  66. err_message = str(err.get("message") or "").strip()
  67. return err_type == "account_deactivated" or err_code == "account_deactivated" or bool(P_DEACTIVATED.search(err_message))
  68. def _extract_entry_account_id(entry: dict) -> str:
  69. id_token = entry.get("id_token")
  70. if isinstance(id_token, dict):
  71. account_id = str(id_token.get("chatgpt_account_id") or id_token.get("account_id") or "").strip()
  72. if account_id:
  73. return account_id
  74. return str(entry.get("account_id") or "").strip()
  75. def _extract_header_value(headers: object, key: str) -> str:
  76. if not isinstance(headers, dict):
  77. return ""
  78. for header_key, header_value in headers.items():
  79. if str(header_key or "").strip().lower() != key.strip().lower():
  80. continue
  81. if isinstance(header_value, list):
  82. for item in header_value:
  83. value = str(item or "").strip()
  84. if value:
  85. return value
  86. return ""
  87. return str(header_value or "").strip()
  88. return ""
  89. def _can_probe_quota(entry: dict) -> bool:
  90. provider = str(entry.get("provider") or "").strip().lower()
  91. if provider and provider != "codex":
  92. return False
  93. auth_index = str(entry.get("auth_index") or "").strip()
  94. account_id = _extract_entry_account_id(entry)
  95. return bool(auth_index and account_id)
  96. def _probe_quota_status(entry: dict, key: str, management_base_url: str) -> tuple[int, str, bool]:
  97. auth_index = str(entry.get("auth_index") or "").strip()
  98. account_id = _extract_entry_account_id(entry)
  99. if not auth_index or not account_id:
  100. return 0, "missing auth_index or account_id", False
  101. body = json.dumps(
  102. {
  103. "authIndex": auth_index,
  104. "method": "GET",
  105. "url": QUOTA_VALIDATE_URL,
  106. "header": build_browser_headers(
  107. access_token="$TOKEN$",
  108. account_id=account_id,
  109. accept="application/json",
  110. content_type="application/json",
  111. ),
  112. },
  113. ensure_ascii=False,
  114. separators=(",", ":"),
  115. ).encode("utf-8")
  116. status, payload = cpa_management_request(
  117. "POST",
  118. "api-call",
  119. key,
  120. management_base_url=management_base_url,
  121. body=body,
  122. content_type="application/json",
  123. timeout=60,
  124. )
  125. if status == 0 or not isinstance(payload, dict):
  126. return 0, "quota probe unavailable", False
  127. probe_status_code = payload.get("status_code") or payload.get("statusCode") or 0
  128. try:
  129. probe_status_code = int(probe_status_code)
  130. except Exception:
  131. probe_status_code = 0
  132. headers = payload.get("header") or payload.get("headers") or {}
  133. raw_body = payload.get("body")
  134. if isinstance(raw_body, str):
  135. body_text = raw_body
  136. elif raw_body is None:
  137. body_text = ""
  138. else:
  139. try:
  140. body_text = json.dumps(raw_body, ensure_ascii=False)
  141. except Exception:
  142. body_text = str(raw_body)
  143. header_auth_error = _extract_header_value(headers, "X-Openai-Authorization-Error")
  144. header_error_code = _extract_header_value(headers, "X-Openai-Ide-Error-Code")
  145. deactivated = is_deactivated_status_message(body_text) or header_error_code == "account_deactivated"
  146. body_code = classify_status_message(body_text)
  147. if (
  148. probe_status_code == 401
  149. or header_auth_error == "401"
  150. or header_error_code in {"token_invalidated", "account_deactivated"}
  151. or body_code == 401
  152. ):
  153. detail = body_text or header_error_code or header_auth_error or "quota probe returned 401"
  154. return 401, _compact_text(detail), deactivated
  155. if probe_status_code == 429 or body_code == 429:
  156. detail = body_text or "quota probe returned 429"
  157. return 429, _compact_text(detail), False
  158. if probe_status_code == 200:
  159. return 200, _compact_text(body_text or "active"), False
  160. return 0, _compact_text(body_text or f"quota probe status={probe_status_code}"), deactivated
  161. def _collect_quota_probe_results(
  162. entries: list[dict],
  163. *,
  164. management_key: str,
  165. management_base_url: str,
  166. max_count: int,
  167. workers: int,
  168. ) -> tuple[dict[str, tuple[int, str, bool]], dict[str, int]]:
  169. probe_candidates = [entry for entry in entries if _can_probe_quota(entry)]
  170. skipped = 0
  171. if max_count > 0 and len(probe_candidates) > max_count:
  172. skipped = len(probe_candidates) - max_count
  173. probe_candidates = probe_candidates[:max_count]
  174. results: dict[str, tuple[int, str, bool]] = {}
  175. if not probe_candidates:
  176. return results, {"probed": 0, "probe_401": 0, "probe_429": 0, "probe_skipped": skipped}
  177. max_workers = max(1, min(int(workers), len(probe_candidates)))
  178. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  179. future_map = {
  180. executor.submit(_probe_quota_status, entry, management_key, management_base_url): str(entry.get("name", ""))
  181. for entry in probe_candidates
  182. }
  183. for future in as_completed(future_map):
  184. name = future_map[future]
  185. try:
  186. results[name] = future.result()
  187. except Exception as exc:
  188. results[name] = (0, _compact_text(str(exc) or "quota probe failed"), False)
  189. counters = {
  190. "probed": len(results),
  191. "probe_401": sum(1 for code, _detail, _deactivated in results.values() if code == 401),
  192. "probe_429": sum(1 for code, _detail, _deactivated in results.values() if code == 429),
  193. "probe_skipped": skipped,
  194. }
  195. return results, counters
  196. def _needs_service_probe(entry: dict) -> bool:
  197. provider = str(entry.get("provider") or "").strip().lower()
  198. if provider and provider != "codex":
  199. return False
  200. status = str(entry.get("status") or "").strip().lower()
  201. if status != "error":
  202. return False
  203. status_message = str(entry.get("status_message") or "").strip()
  204. return classify_status_message(status_message) == 0