Skip to content

Commit 59bac2e

Browse files
author
45371
committed
Initial commit: Codex usage widget
0 parents  commit 59bac2e

11 files changed

Lines changed: 749 additions & 0 deletions

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
__pycache__/
2+
*.pyc
3+
*.pyo
4+
*.pyd
5+
6+
# runtime/cache artifacts
7+
last_usage.json
8+
widget_config.json
9+
widget_start.log
10+
screen_check*.png
11+
12+
# local debug helpers
13+
unlock_try.ps1

README.txt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
Codex 用量桌面显示(Windows)
2+
3+
你现在有一个桌面小组件,会显示 Codex 的 5小时/7天 用量。
4+
5+
文件说明:
6+
1) 启动Codex用量桌显.bat 双击启动(黑色命令行窗口已隐藏)
7+
2) codex_usage_widget.ps1 桌面悬浮窗(右下角)
8+
3) codex_usage_fetch.py 负责请求接口并返回用量
9+
4) last_usage.json 最近一次成功数据缓存(自动生成)
10+
5) widget_config.json 你的样式配置(自动生成)
11+
12+
可调功能:
13+
1) 右键悬浮窗 -> 透明度(20%~100%)
14+
2) 右键悬浮窗 -> 图层:
15+
- 桌面最顶端(始终置顶)
16+
- 活动窗口后(不置顶,跟普通窗口层级)
17+
18+
背景颜色会自动跟随系统任务栏风格(明/暗主题)。
19+
以上设置会自动保存,下次启动保持不变。
20+
21+
使用方法:
22+
1. 先确认你已登录 Codex
23+
2. 双击“启动Codex用量桌显.bat”
24+
3. 右下角会出现悬浮窗:
25+
- 5h:已用X% / 剩余Y%
26+
- 7d:已用A% / 剩余B%
27+
4. 每60秒自动刷新一次
28+
5. 右键可切换图层(桌面最顶端/活动窗口后)
29+
6. 双击悬浮窗可关闭

capture_screen.ps1

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Add-Type -AssemblyName System.Windows.Forms
2+
Add-Type -AssemblyName System.Drawing
3+
$b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds
4+
$bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height
5+
$g=[System.Drawing.Graphics]::FromImage($bmp)
6+
$g.CopyFromScreen($b.X,$b.Y,0,0,$b.Size)
7+
$g.Dispose()
8+
$p='C:\Users\45371\Desktop\codex-usage-widget\screen_check.png'
9+
$bmp.Save($p,[System.Drawing.Imaging.ImageFormat]::Png)
10+
$bmp.Dispose()
11+
Write-Output $p

check_widget_proc.ps1

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
$procs = Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'powershell.exe' -and $_.CommandLine -like '*codex_usage_widget.ps1*' }
2+
if ($procs) {
3+
$procs | Select-Object ProcessId, Name, CommandLine | ConvertTo-Json -Depth 3
4+
} else {
5+
'NONE'
6+
}

codex_usage_fetch.py

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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

Comments
 (0)