204 lines
6.4 KiB
Python
204 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from tempfile import NamedTemporaryFile
|
|
from typing import Optional
|
|
|
|
|
|
def iso_from_epoch(value):
|
|
if value is None:
|
|
return None
|
|
return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat()
|
|
|
|
|
|
def clamp_percent(value):
|
|
value = max(0.0, min(100.0, float(value)))
|
|
return round(value, 2)
|
|
|
|
|
|
def discover_zen_cookie_db(home_dir: Path) -> Optional[Path]:
|
|
zen_root = home_dir / ".zen"
|
|
if not zen_root.exists():
|
|
return None
|
|
|
|
for path in sorted(zen_root.glob("*/cookies.sqlite")):
|
|
if path.is_file():
|
|
return path
|
|
|
|
return None
|
|
|
|
|
|
def read_chatgpt_cookies(cookie_db: Path, context_prefix: str) -> dict[str, str]:
|
|
with NamedTemporaryFile(delete=False) as handle:
|
|
temp_db = Path(handle.name)
|
|
|
|
try:
|
|
shutil.copy2(cookie_db, temp_db)
|
|
conn = sqlite3.connect(temp_db)
|
|
try:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT name, value
|
|
FROM moz_cookies
|
|
WHERE host LIKE ?
|
|
AND originAttributes LIKE ?
|
|
ORDER BY name
|
|
""",
|
|
("%chatgpt.com", f"{context_prefix}%"),
|
|
).fetchall()
|
|
finally:
|
|
conn.close()
|
|
finally:
|
|
temp_db.unlink(missing_ok=True)
|
|
|
|
return {name: value for name, value in rows}
|
|
|
|
|
|
def fetch_json(url: str, headers: dict[str, str]) -> dict:
|
|
request = urllib.request.Request(url, headers=headers)
|
|
with urllib.request.urlopen(request, timeout=20) as response:
|
|
return json.load(response)
|
|
|
|
|
|
def build_web_status(home_dir: Path):
|
|
cookie_db = Path(
|
|
os.environ.get("CHATGPT_COOKIE_DB")
|
|
or discover_zen_cookie_db(home_dir)
|
|
or ""
|
|
)
|
|
context_prefix = os.environ.get("CHATGPT_COOKIE_CONTEXT_PREFIX", "^userContextId=1")
|
|
|
|
if not cookie_db.is_file():
|
|
return None
|
|
|
|
cookies = read_chatgpt_cookies(cookie_db, context_prefix)
|
|
if not cookies:
|
|
return None
|
|
|
|
cookie_header = "; ".join(f"{name}={value}" for name, value in cookies.items())
|
|
base_headers = {
|
|
"Accept": "application/json",
|
|
"Cookie": cookie_header,
|
|
"Referer": "https://chatgpt.com/codex/cloud/settings/analytics",
|
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
|
}
|
|
|
|
try:
|
|
session_data = fetch_json("https://chatgpt.com/api/auth/session", base_headers)
|
|
except (OSError, sqlite3.Error, urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError):
|
|
return None
|
|
|
|
access_token = session_data.get("accessToken")
|
|
if not access_token:
|
|
return None
|
|
|
|
wham_headers = {
|
|
**base_headers,
|
|
"Accept": "application/json, text/plain, */*",
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Origin": "https://chatgpt.com",
|
|
}
|
|
|
|
try:
|
|
usage_data = fetch_json("https://chatgpt.com/backend-api/wham/usage", wham_headers)
|
|
except (OSError, urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError):
|
|
return None
|
|
|
|
rate_limit = usage_data.get("rate_limit") or {}
|
|
primary = rate_limit.get("primary_window") or {}
|
|
secondary = rate_limit.get("secondary_window") or {}
|
|
|
|
if "used_percent" not in primary or "used_percent" not in secondary:
|
|
return None
|
|
|
|
return {
|
|
"plan_type": usage_data.get("plan_type"),
|
|
"five_hour_left_percent": clamp_percent(100.0 - float(primary["used_percent"])),
|
|
"five_hour_resets_at": iso_from_epoch(primary.get("reset_at")),
|
|
"weekly_left_percent": clamp_percent(100.0 - float(secondary["used_percent"])),
|
|
"weekly_resets_at": iso_from_epoch(secondary.get("reset_at")),
|
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
"source": "chatgpt_web",
|
|
"source_file": str(cookie_db),
|
|
}
|
|
|
|
|
|
def extract_status(session_root):
|
|
files = sorted(session_root.rglob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
|
|
for path in files:
|
|
try:
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
lines = handle.readlines()
|
|
except OSError:
|
|
continue
|
|
|
|
for line in reversed(lines):
|
|
try:
|
|
event = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
if event.get("type") != "event_msg":
|
|
continue
|
|
|
|
payload = event.get("payload") or {}
|
|
if payload.get("type") != "token_count":
|
|
continue
|
|
|
|
info = payload.get("info") or {}
|
|
rate_limits = payload.get("rate_limits") or {}
|
|
|
|
primary = rate_limits.get("primary") or {}
|
|
secondary = rate_limits.get("secondary") or {}
|
|
|
|
if "used_percent" not in primary or "used_percent" not in secondary:
|
|
continue
|
|
|
|
return {
|
|
"plan_type": rate_limits.get("plan_type"),
|
|
"five_hour_left_percent": clamp_percent(100.0 - float(primary["used_percent"])),
|
|
"five_hour_resets_at": iso_from_epoch(primary.get("resets_at")),
|
|
"weekly_left_percent": clamp_percent(100.0 - float(secondary["used_percent"])),
|
|
"weekly_resets_at": iso_from_epoch(secondary.get("resets_at")),
|
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
"source_file": str(path),
|
|
}
|
|
|
|
return None
|
|
|
|
|
|
def main():
|
|
output_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/var/lib/hass/codex_status.json")
|
|
home_dir = Path(os.environ.get("HOME", str(Path.home())))
|
|
session_root = Path(os.environ.get("CODEX_SESSION_ROOT", str(home_dir / ".codex" / "sessions")))
|
|
|
|
status = build_web_status(home_dir) or extract_status(session_root)
|
|
if status is None:
|
|
print(
|
|
f"No Codex web usage data or token_count event found for {home_dir}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with NamedTemporaryFile("w", encoding="utf-8", dir=output_path.parent, delete=False) as handle:
|
|
json.dump(status, handle, ensure_ascii=False, separators=(",", ":"))
|
|
handle.write("\n")
|
|
temp_path = Path(handle.name)
|
|
|
|
temp_path.replace(output_path)
|
|
output_path.chmod(0o644)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|