|
| 1 | +#!/usr/bin/env python3 |
| 2 | +import json |
| 3 | +import time |
| 4 | +import base64 |
| 5 | +from pathlib import Path |
| 6 | +from urllib.parse import urlencode |
| 7 | +from urllib.request import Request, urlopen |
| 8 | +from urllib.error import URLError, HTTPError |
| 9 | + |
| 10 | +TOKEN_URL = "https://auth.openai.com/oauth/token" |
| 11 | +USAGE_URLS = [ |
| 12 | + "https://chatgpt.com/backend-api/wham/usage", |
| 13 | + "https://chatgpt.com/backend-api/codex/usage", |
| 14 | +] |
| 15 | +CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" |
| 16 | +AUTH_CANDIDATES = [ |
| 17 | + Path.home() / ".hermes" / "auth.json", |
| 18 | + Path.home() / ".codex" / "auth.json", |
| 19 | +] |
| 20 | +CACHE_PATH = Path(__file__).resolve().parent / "last_usage.json" |
| 21 | + |
| 22 | + |
| 23 | +def _b64url_decode(seg: str) -> bytes: |
| 24 | + seg += "=" * ((4 - len(seg) % 4) % 4) |
| 25 | + return base64.urlsafe_b64decode(seg.encode()) |
| 26 | + |
| 27 | + |
| 28 | +def _load_auth(): |
| 29 | + # 新版 Hermes auth.json: credential_pool.openai-codex[0] |
| 30 | + hermes_auth = Path.home() / ".hermes" / "auth.json" |
| 31 | + if hermes_auth.exists(): |
| 32 | + try: |
| 33 | + data = json.loads(hermes_auth.read_text(encoding="utf-8")) |
| 34 | + pool = (data.get("credential_pool") or {}).get("openai-codex") or [] |
| 35 | + if isinstance(pool, list) and pool: |
| 36 | + cred = pool[0] if isinstance(pool[0], dict) else {} |
| 37 | + access = cred.get("access_token") |
| 38 | + refresh = cred.get("refresh_token") |
| 39 | + account_id = cred.get("account_id") |
| 40 | + if (not account_id) and isinstance(access, str) and "." in access: |
| 41 | + try: |
| 42 | + payload = json.loads(_b64url_decode(access.split(".")[1])) |
| 43 | + account_id = (payload.get("https://api.openai.com/auth") or {}).get("chatgpt_account_id") |
| 44 | + except Exception: |
| 45 | + account_id = None |
| 46 | + if access and refresh: |
| 47 | + return hermes_auth, data, cred, access, refresh, account_id |
| 48 | + except Exception: |
| 49 | + pass |
| 50 | + |
| 51 | + # 旧版 auth.json 兼容:tokens 结构 |
| 52 | + last_err = None |
| 53 | + for auth_path in AUTH_CANDIDATES: |
| 54 | + if not auth_path.exists(): |
| 55 | + continue |
| 56 | + try: |
| 57 | + data = json.loads(auth_path.read_text(encoding="utf-8")) |
| 58 | + tokens = data.get("tokens") or {} |
| 59 | + access = tokens.get("access_token") |
| 60 | + refresh = tokens.get("refresh_token") |
| 61 | + id_token = tokens.get("id_token") |
| 62 | + account_id = tokens.get("account_id") |
| 63 | + if not account_id and isinstance(id_token, str) and "." in id_token: |
| 64 | + try: |
| 65 | + payload = json.loads(_b64url_decode(id_token.split(".")[1])) |
| 66 | + account_id = (payload.get("https://api.openai.com/auth") or {}).get("chatgpt_account_id") |
| 67 | + except Exception: |
| 68 | + account_id = None |
| 69 | + if access and refresh: |
| 70 | + return auth_path, data, tokens, access, refresh, account_id |
| 71 | + last_err = f"{auth_path} 缺少 access_token 或 refresh_token" |
| 72 | + except Exception as e: |
| 73 | + last_err = f"{auth_path} 读取失败: {e}" |
| 74 | + |
| 75 | + if last_err: |
| 76 | + raise RuntimeError(last_err) |
| 77 | + raise RuntimeError("未找到 auth.json(已检查 ~/.hermes/auth.json 与 ~/.codex/auth.json)") |
| 78 | + |
| 79 | + |
| 80 | +def _http_json(url: str, method: str = "GET", headers=None, body=None, timeout=20): |
| 81 | + req = Request(url, data=body, method=method) |
| 82 | + for k, v in (headers or {}).items(): |
| 83 | + req.add_header(k, v) |
| 84 | + with urlopen(req, timeout=timeout) as r: |
| 85 | + raw = r.read().decode("utf-8", errors="replace") |
| 86 | + return r.getcode(), json.loads(raw) |
| 87 | + |
| 88 | + |
| 89 | +def _refresh(refresh_token: str): |
| 90 | + payload = urlencode({ |
| 91 | + "grant_type": "refresh_token", |
| 92 | + "refresh_token": refresh_token, |
| 93 | + "client_id": CLIENT_ID, |
| 94 | + }).encode("utf-8") |
| 95 | + code, data = _http_json( |
| 96 | + TOKEN_URL, |
| 97 | + method="POST", |
| 98 | + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, |
| 99 | + body=payload, |
| 100 | + timeout=20, |
| 101 | + ) |
| 102 | + if code != 200: |
| 103 | + raise RuntimeError(f"refresh失败: HTTP {code}") |
| 104 | + if not data.get("access_token"): |
| 105 | + raise RuntimeError("refresh成功但无access_token") |
| 106 | + return data |
| 107 | + |
| 108 | + |
| 109 | +def _save_auth(auth_path: Path, full_auth: dict, old_tokens: dict, refreshed: dict): |
| 110 | + new_tokens = dict(old_tokens) |
| 111 | + new_tokens["access_token"] = refreshed.get("access_token", old_tokens.get("access_token")) |
| 112 | + if refreshed.get("refresh_token"): |
| 113 | + new_tokens["refresh_token"] = refreshed["refresh_token"] |
| 114 | + if refreshed.get("id_token"): |
| 115 | + new_tokens["id_token"] = refreshed["id_token"] |
| 116 | + |
| 117 | + # 新版 Hermes credential_pool 结构 |
| 118 | + if isinstance(full_auth.get("credential_pool"), dict): |
| 119 | + pool = full_auth["credential_pool"].get("openai-codex") |
| 120 | + if isinstance(pool, list) and pool: |
| 121 | + cred0 = pool[0] |
| 122 | + if isinstance(cred0, dict): |
| 123 | + cred0["access_token"] = new_tokens.get("access_token") |
| 124 | + if new_tokens.get("refresh_token"): |
| 125 | + cred0["refresh_token"] = new_tokens.get("refresh_token") |
| 126 | + full_auth["credential_pool"]["openai-codex"][0] = cred0 |
| 127 | + auth_path.write_text(json.dumps(full_auth, ensure_ascii=False, indent=2), encoding="utf-8") |
| 128 | + return cred0 |
| 129 | + |
| 130 | + # 旧版 tokens 结构 |
| 131 | + full_auth["tokens"] = new_tokens |
| 132 | + auth_path.write_text(json.dumps(full_auth, ensure_ascii=False, indent=2), encoding="utf-8") |
| 133 | + return new_tokens |
| 134 | + |
| 135 | + |
| 136 | +def _build_cloudflare_friendly_headers(access_token: str, account_id: str | None): |
| 137 | + headers = { |
| 138 | + "Authorization": f"Bearer {access_token}", |
| 139 | + "Accept": "application/json", |
| 140 | + "Content-Type": "application/json", |
| 141 | + "Origin": "https://chatgpt.com", |
| 142 | + "Referer": "https://chatgpt.com/codex", |
| 143 | + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", |
| 144 | + "Sec-Fetch-Site": "same-origin", |
| 145 | + "Sec-Fetch-Mode": "cors", |
| 146 | + "Sec-Fetch-Dest": "empty", |
| 147 | + } |
| 148 | + if account_id: |
| 149 | + headers["ChatGPT-Account-Id"] = account_id |
| 150 | + return headers |
| 151 | + |
| 152 | + |
| 153 | +def _fetch_usage(access_token: str, account_id: str | None): |
| 154 | + headers = _build_cloudflare_friendly_headers(access_token, account_id) |
| 155 | + last_err = None |
| 156 | + for url in USAGE_URLS: |
| 157 | + try: |
| 158 | + code, data = _http_json(url, headers=headers, timeout=20) |
| 159 | + if code == 200: |
| 160 | + return data |
| 161 | + last_err = RuntimeError(f"usage失败: HTTP {code} ({url})") |
| 162 | + except Exception as e: |
| 163 | + last_err = e |
| 164 | + if last_err: |
| 165 | + raise last_err |
| 166 | + raise RuntimeError("usage失败: 未知错误") |
| 167 | + |
| 168 | + |
| 169 | +def _build_result(usage: dict): |
| 170 | + rl = usage.get("rate_limit") or {} |
| 171 | + p = rl.get("primary_window") or {} |
| 172 | + s = rl.get("secondary_window") or {} |
| 173 | + |
| 174 | + p_used = int(p.get("used_percent", 0) or 0) |
| 175 | + s_used = int(s.get("used_percent", 0) or 0) |
| 176 | + p_left = max(0, 100 - p_used) |
| 177 | + s_left = max(0, 100 - s_used) |
| 178 | + p_reset = int(p.get("reset_after_seconds", 0) or 0) |
| 179 | + s_reset = int(s.get("reset_after_seconds", 0) or 0) |
| 180 | + |
| 181 | + return { |
| 182 | + "ok": True, |
| 183 | + "from_cache": False, |
| 184 | + "ts": int(time.time()), |
| 185 | + "plan": usage.get("plan_type", "unknown"), |
| 186 | + "allowed": bool((rl.get("allowed") if isinstance(rl, dict) else False)), |
| 187 | + "limit_reached": bool((rl.get("limit_reached") if isinstance(rl, dict) else False)), |
| 188 | + "primary": {"used": p_used, "left": p_left, "reset_seconds": p_reset}, |
| 189 | + "secondary": {"used": s_used, "left": s_left, "reset_seconds": s_reset}, |
| 190 | + } |
| 191 | + |
| 192 | + |
| 193 | +def _load_cache(): |
| 194 | + if CACHE_PATH.exists(): |
| 195 | + try: |
| 196 | + return json.loads(CACHE_PATH.read_text(encoding="utf-8")) |
| 197 | + except Exception: |
| 198 | + return None |
| 199 | + return None |
| 200 | + |
| 201 | + |
| 202 | +def main(): |
| 203 | + try: |
| 204 | + auth_path, auth, tokens, access, refresh, account_id = _load_auth() |
| 205 | + if (not account_id) and isinstance(access, str) and "." in access: |
| 206 | + try: |
| 207 | + payload = json.loads(_b64url_decode(access.split(".")[1])) |
| 208 | + account_id = (payload.get("https://api.openai.com/auth") or {}).get("chatgpt_account_id") |
| 209 | + except Exception: |
| 210 | + account_id = None |
| 211 | + |
| 212 | + # 先用现有 access_token(实测更稳定);401 再 refresh 重试 |
| 213 | + try: |
| 214 | + usage = _fetch_usage(access, account_id) |
| 215 | + except HTTPError as e: |
| 216 | + if getattr(e, "code", None) != 401: |
| 217 | + raise |
| 218 | + refreshed = _refresh(refresh) |
| 219 | + tokens = _save_auth(auth_path, auth, tokens, refreshed) |
| 220 | + access = tokens.get("access_token") |
| 221 | + if (not account_id) and isinstance(access, str) and "." in access: |
| 222 | + try: |
| 223 | + payload = json.loads(_b64url_decode(access.split(".")[1])) |
| 224 | + account_id = (payload.get("https://api.openai.com/auth") or {}).get("chatgpt_account_id") |
| 225 | + except Exception: |
| 226 | + account_id = None |
| 227 | + usage = _fetch_usage(access, account_id) |
| 228 | + |
| 229 | + result = _build_result(usage) |
| 230 | + CACHE_PATH.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") |
| 231 | + print(json.dumps(result, ensure_ascii=False)) |
| 232 | + except (HTTPError, URLError, TimeoutError, RuntimeError, OSError, json.JSONDecodeError) as e: |
| 233 | + cache = _load_cache() |
| 234 | + if cache: |
| 235 | + cache["ok"] = False |
| 236 | + cache["error"] = str(e) |
| 237 | + cache["from_cache"] = True |
| 238 | + print(json.dumps(cache, ensure_ascii=False)) |
| 239 | + return |
| 240 | + print(json.dumps({"ok": False, "error": str(e), "from_cache": False}, ensure_ascii=False)) |
| 241 | + |
| 242 | + |
| 243 | +if __name__ == "__main__": |
| 244 | + main() |
0 commit comments