Files
eink-push/codex_usage.py
YANG JIANKUAN 7378d6bb88 feat: AI 用量改账户级本机采集(Claude 钥匙串/Codex RPC)+宽版 7d 合并行
- Claude:复用 Claude Code 钥匙串登录态直调 /api/oauth/usage,零依赖 statusline 与 Claude Code 进程;只读不刷新 token
- Codex:经 codex app-server JSON-RPC 读 ChatGPT 订阅的 Codex 额度池(普通聊天额度无账户级途径,右块改名 Codex Usage)
- 两路采集共用 TTL 缓存+失败退避(CLAUDE_USAGE_TTL/CODEX_USAGE_TTL,语义同 WEATHER_TTL),推送端两路独立上报
- E1002:Claude 7d 与 fable 7d 合并为一行(单边框内上下对半实心,数字取较大者)、标签去 all 前缀、空值按 00% 00 占位等宽
- E1002/网页版:今日实时环比同比改涨绿跌红,与 K 线一致

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 14:08:21 +08:00

148 lines
7.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Codex 账户级用量 —— 通过官方 `codex app-server` JSON-RPC 读 ChatGPT 订阅的 Codex 额度5 小时 / 7 天窗口)。
背景2026-09 调研结论ChatGPT 订阅的**普通聊天**额度没有任何可编程的账户级读取途径(私有端点在
Sentinel/Cloudflare 门后、且只在被限流时才返回数值);可监控的账户级实时余额只有 **Codex 额度池**——
Codex CLI / IDE 扩展 / Codex Web / ChatGPT 桌面 App 内的 Codex 共用同一池。本模块读的就是它。
为什么走 app-server RPC 而不是直调 chatgpt.com/backend-api/wham/usagetoken 刷新由 Codex CLI 代管
refresh token 轮换、外部进程自刷会踢掉 CLI 登录),官方 CI 文档也明示「不要自己调刷新接口」。
协议(已在本机 codex-cli 0.154 验证stdio、按行 JSON-RPC——
{"id":1,"method":"initialize","params":{"clientInfo":{...}}} ← {"id":1,"result":{...}}
{"method":"initialized"}
{"id":2,"method":"account/rateLimits/read","params":{"excludeResetCreditDetails":true}}
{"id":2,"result":{"rateLimits":{"primary":{usedPercent,windowDurationMins,resetsAt},"secondary":...,"planType":...},
"rateLimitsByLimitId":{...}}}
API Key 登录时返回 error「chatgpt authentication required to read rate limits」。
登录:需 Codex 以 ChatGPT 账号 `codex login`2026-09-14 用户在默认 ~/.codex 完成,故 CODEX_USAGE_HOME 默认留空跟随默认目录;
若日常 Codex 要保持 API Key/第三方中转登录,可设独立 CODEX_HOME 并在其下 `CODEX_HOME=… codex login`。token 由 CLI 自管。
CODEX_BIN 由 config 解析为绝对路径launchd 的 PATH 不含 /opt/homebrew/bin曾因此报「找不到 codex 可执行文件」)。窗口归类按 windowDurationMinsPro 计划常只回一个窗口、另一个为 null
不能按 primary/secondary 位置判断)。输出快照与 Claude 同构data.usage_from_raw 直接消费:
{five_hour:{utilization, resets_at, window}, seven_day:{...}, plan, updated_at, source}
"""
import json
import os
import select
import subprocess
import time
import config
import usage_common
RPC_TIMEOUT = 25 # 秒:含 app-server 启动 + 一次远端查询
def _env():
env = {k: v for k, v in os.environ.items()
if k.upper() not in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY")} # 直连,不走代理
if config.CODEX_USAGE_HOME:
home = os.path.expanduser(config.CODEX_USAGE_HOME)
os.makedirs(home, exist_ok=True) # codex 对不存在的 CODEX_HOME 直接退出;建空目录后它会给出清晰的「未登录」错误
env["CODEX_HOME"] = home
return env
def _rpc_read_rate_limits():
"""起一个 app-server 子进程,完成握手并读一次额度;返回 account/rateLimits/read 的 result。"""
try:
p = subprocess.Popen([config.CODEX_BIN, "app-server"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, env=_env())
except FileNotFoundError as e:
raise RuntimeError(f"找不到 codex 可执行文件CODEX_BIN={config.CODEX_BIN}") from e
deadline = time.time() + RPC_TIMEOUT
def send(obj):
p.stdin.write(json.dumps(obj) + "\n")
p.stdin.flush()
def wait_for(rid):
while time.time() < deadline:
r, _, _ = select.select([p.stdout], [], [], 0.2)
if not r:
if p.poll() is not None:
raise RuntimeError(f"app-server 提前退出:{p.stderr.read()[:300]}")
continue
line = p.stdout.readline()
if not line:
raise RuntimeError(f"app-server 关闭了输出:{p.stderr.read()[:300]}")
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
if msg.get("id") != rid:
continue # 通知remoteControl/status/changed 等)一律跳过
if "error" in msg:
err = msg["error"].get("message", str(msg["error"]))
hint = ""
if "authentication required" in err.lower():
hint = (f";请先执行 {'CODEX_HOME=' + config.CODEX_USAGE_HOME + ' ' if config.CODEX_USAGE_HOME else ''}"
f"codex login 用 ChatGPT 账号登录")
raise RuntimeError(f"RPC 错误:{err}{hint}")
return msg.get("result") or {}
raise RuntimeError(f"app-server 在 {RPC_TIMEOUT}s 内未响应 id={rid}")
try:
send({"id": 1, "method": "initialize",
"params": {"clientInfo": {"name": "eink-push", "title": "eink-push usage", "version": "0.1"}}})
wait_for(1)
send({"method": "initialized"})
send({"id": 2, "method": "account/rateLimits/read", "params": {"excludeResetCreditDetails": True}})
return wait_for(2)
finally:
try:
p.kill()
except OSError:
pass
def _classify(win, fallback_key):
"""按窗口长度归类≤12 小时 → five_hour≥5 天 → seven_day缺 windowDurationMins 时按位置兜底。"""
mins = win.get("windowDurationMins")
if mins is None:
return fallback_key
if mins <= 12 * 60:
return "five_hour"
if mins >= 5 * 24 * 60:
return "seven_day"
return None
def normalize(result, now=None):
now = now or time.time()
rl = result.get("rateLimits") or {}
out = {"five_hour": None, "seven_day": None, "plan": rl.get("planType"),
"updated_at": int(now), "source": "codex-app-server"}
for pos, fallback in (("primary", "five_hour"), ("secondary", "seven_day")):
win = rl.get(pos)
if not isinstance(win, dict) or win.get("usedPercent") is None:
continue
key = _classify(win, fallback)
if key and out.get(key) is None:
seg = {"utilization": float(win["usedPercent"]), "resets_at": win.get("resetsAt")}
if win.get("windowDurationMins"):
seg["window"] = int(win["windowDurationMins"]) * 60 # 秒,供 pace 用真实窗口长度
out[key] = seg
# 多桶视图原样附带key 为 limit_id如 codex目前不渲染留作观察真实数据后再决定是否加第三行
by_id = result.get("rateLimitsByLimitId")
if isinstance(by_id, dict) and by_id:
out["buckets"] = {k: {"primary": (v or {}).get("primary"), "secondary": (v or {}).get("secondary"),
"limitName": (v or {}).get("limitName")} for k, v in by_id.items()}
return out
def get_usage(force=False):
"""对外入口:带缓存/最小间隔/退避的 Codex 额度快照。未登录 ChatGPT 账号且无旧快照时抛出。"""
snap, status = usage_common.cached_snapshot(
config.CODEX_USAGE_CACHE_PATH, config.CODEX_USAGE_TTL, config.CODEX_USAGE_BACKOFF,
lambda: normalize(_rpc_read_rate_limits()), force=force)
snap = dict(snap)
snap["status"] = status
return snap
if __name__ == "__main__":
import sys
print(json.dumps(get_usage(force="--force" in sys.argv), ensure_ascii=False, indent=1))