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>
This commit is contained in:
147
codex_usage.py
Normal file
147
codex_usage.py
Normal file
@@ -0,0 +1,147 @@
|
||||
#!/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/usage:token 刷新由 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 可执行文件」)。窗口归类按 windowDurationMins(Pro 计划常只回一个窗口、另一个为 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))
|
||||
Reference in New Issue
Block a user