上一篇把 TRMNL 墨水屏从官方云切到了自建服务端,仪表盘上有一块 AI 面板,显示当天 Claude Code 和 Codex 烧了多少 token——一个甜甜圈加一个月用量条。
问题是这个数字没用。「今天烧了 1.4 亿 token」既不能让我少烧,也不能告诉我还能不能接着干。真正想看的是另一个东西:5 小时滚动窗口和周窗口的剩余额度。订阅版的 Claude Code(Pro/Max)和 Codex(ChatGPT Plus/Pro)都按这两个窗口限流,撞墙了就得等窗口重置。墨水屏摆在桌上,一眼扫过去知道「5h 还剩 90%、周还剩 94%」,这才是能据此决策的信息。
需求一句话:AI 面板从「用量」改成「额度」,5h 和 1w 两个窗口,Claude Code 和 Codex 都要。
下面是把这件事做完整的全过程——数据从哪来、怎么渲染、谁来上报、以及 headless 机器上最麻烦的 token 续期。
数据从哪来:/usage 背后的接口
Claude Code 有个 /usage 斜杠命令,能显示 5h 和周窗口的百分比和重置时间。Codex 也在会话 rollout 的 JSONL 里写了 rate_limits。要在服务端复现,得知道这些数字的源头。
先说结论,两个都是未公开的 OAuth 接口,用本机已登录的 token 直接打就能拿到:
Claude Code — GET https://api.anthropic.com/api/oauth/usage
token 在 macOS Keychain 里(服务名 Claude Code-credentials),或者 Linux 的 ~/.claude/.credentials.json 的 claudeAiOauth.accessToken。我用 shell 把 token 取出来直接打,token 全程不进上下文:
CC_TOK=$(security find-generic-password -s "Claude Code-credentials" -w \ | python3 -c "import sys,json; print(json.load(sys.stdin)['claudeAiOauth']['accessToken'])")curl -s https://api.anthropic.com/api/oauth/usage \ -H "Authorization: Bearer $CC_TOK" \ -H "anthropic-beta: oauth-2025-04-20" \ -H "User-Agent: claude-code/2.1.0" | python3 -m json.tool返回:
{ "five_hour": { "utilization": 6.0, "resets_at": "2026-06-14T18:00:00+00:00" }, "seven_day": { "utilization": 5.0, "resets_at": "2026-06-18T15:00:00+00:00" }, "seven_day_opus": null, "seven_day_sonnet": { "utilization": 2.0, "resets_at": "..." }, "extra_usage": { "is_enabled": true, "used_credits": 2093.0, ... }}utilization 是已用百分比(0–100),剩余就是 100 - utilization。resets_at 是 ISO 8601 的窗口重置时间。
User-Agent 不是装饰那个
User-Agent: claude-code/<version>必须带。少了它,请求会落进一个被狠狠限流的桶里,持续返回 429,而且没有Retry-After,退避也救不回来。带上正常的 UA,30 秒一次的轮询完全安全。
Codex — GET https://chatgpt.com/backend-api/wham/usage
token 在 ~/.codex/auth.json 的 tokens.access_token,还要带 chatgpt-account-id。我试过 /backend-api/codex/usage 和 /api/codex/usage,都返回 403,只有 wham/usage 通:
{ "plan_type": "plus", "rate_limit": { "primary_window": { "used_percent": 1, "limit_window_seconds": 18000, "reset_at": 1781470751 }, "secondary_window": { "used_percent": 7, "limit_window_seconds": 604800, "reset_at": 1781765014 } }}primary_window 的 limit_window_seconds 是 18000 = 5 小时,secondary_window 是 604800 = 7 天。used_percent 是整数,reset_at 是 epoch 秒。
这里有个关键认知,决定了后面整个上报架构:这两个窗口是账号级别的,不是机器级别的。/usage 返回的是你这个 Anthropic / OpenAI 账号的全局窗口状态,跟哪台机器发的请求无关。也就是说,任何一台登录了同一账号的机器,拉到的额度数字都一样。
渲染:四条剩余额度条
服务端渲染器(Pillow 画 1-bit BMP,细节见上一篇)里,把原来的 token 甜甜圈换成四条窗口的剩余额度条。数据结构很直白,每个源存「已用百分比 + 重置 epoch」,渲染时算 100 - used 当剩余,条形填充比例就是剩余比例,右边跟一个紧凑的重置倒计时:
AI · LIMITSCLAUDE 5h ▓▓▓▓▓▓▓▓▓░ 91% ↺1h 1w ▓▓▓▓▓▓▓▓▓░ 94% ↺3dCODEX 5h ▓▓▓▓▓▓▓▓▓▓ 99% ↺4h 1w ▓▓▓▓▓▓▓▓▓░ 93% ↺3d倒计时用窗口的 reset_at 减当前时间,按量级显示 45m / 2h / 3d。一个小细节:倒计时要用「画面里的 now」算,而不是渲染时的墙钟——服务端预览用的是固定的样例时间戳,如果用墙钟算,样例图的倒计时全是 0m。所以把 now_epoch 从 ScreenData.now 一路传进面板函数。
新增一个 POST /api/usage/quota 入口和一张 ai_quota 快照表(每个源一行:5h/7d 的 used% 和重置 epoch),渲染时读出来喂给面板。
上报架构:服务端没凭据,得有台 Mac 推
服务端跑在一台远程 droplet 上,那上面没有 Claude / Codex 的登录凭据,也没有 Keychain。所以额度不能在服务端「拉」,得在一台登录了账号的机器上「拉了再推」。
幸好上一篇已经有这个模式:一个 report_ai_usage.py 脚本,定时解析本机的 ~/.claude/projects 和 ~/.codex/sessions JSONL 算 token 用量,POST 给服务端。现在给它加上额度抓取——打那两个 usage 接口,把 5h/7d 的 used% 和重置时间推到 /api/usage/quota。
「额度是账号级全局」这个性质在这里很关键:上报机器不需要是你平时干活那台,只要它登录了同一个账号就行。
那放哪台机器跑?我平时在 MacBook 上用 Claude Code 和 Codex,token 是新鲜的——但笔记本会合盖睡眠,一睡 cron 就停了,额度数据变陈旧。家里那台 Mac mini 是常开的,更适合干这种每半小时一次的定时活。于是把上报挪到 mini。
真正的麻烦:headless 机器上的 token 续期
把脚本拷到 mini 上一跑,Claude 接口直接 401。
原因很快查到。mini 上不常用 Claude Code,它的 OAuth access token 早过期了:
# mini 上看 token 过期时间python3 -c "import json,pathlib,time; o=json.loads( (pathlib.Path.home()/'.claude/.credentials.json').read_text())['claudeAiOauth']; print('expiresAt:', round((o['expiresAt']/1000-time.time())/3600,1), 'h')"# expiresAt: -34.5 h ← 34 小时前就过期了access token 大概 8 小时寿命,靠 refresh token 续。CLI 在你正常使用时会无感续期,但一个只读凭据文件的定时脚本不会触发续期。Codex 那边同理,access token 也是 401。
第一反应是脚本自己拿 refresh token 去刷。直接打 Anthropic 的 token 端点:
# 直接刷 Claude tokencurl -s https://console.anthropic.com/v1/oauth/token \ -d '{"grant_type":"refresh_token","refresh_token":"...","client_id":"..."}'# → 429 {"type":"rate_limit_error","message":"Rate limited. Please try again later."}429。Anthropic 的刷新端点限流很凶,直接打不可靠。
转机是想起 mini 上其实装了 claude CLI。CLI 自己就会在 token 临近过期时用正确的姿势续期(它处理了限流退避)。验证一下——跑一个最小的 claude -p,看凭据文件的 expiresAt 有没有更新:
claude -p "reply ok" # → ok# 之后 expiresAt 从 -34.5h 变成了 +8.0h成了。所以 Claude 的续期策略定下来:让 claude CLI 自己刷,脚本只在 token 临近过期时调一次极小的 claude -p。
Codex 这边反而更顺。它的 access token 是 JWT,解出 exp 看寿命:
# 解 codex token 的 exp(只读过期时间,不打印 token)python3 -c "import json,base64,time,pathlib; t=json.loads((pathlib.Path.home()/'.codex/auth.json').read_text())['tokens']['access_token']; p=t.split('.')[1]; p+='='*(-len(p)%4); print('in', round((json.loads(base64.urlsafe_b64decode(p))['exp']-time.time())/3600,1), 'h')"# in 240.0 h ← 10 天10 天寿命,而且 OAuth 刷新端点能直接打通(用 codex 开源仓库里那个公开的 client_id app_EMoamEEZ73f0CkXaXp7hrann,端点 https://auth.openai.com/oauth/token)。所以 Codex 的策略:脚本在临近过期时自己刷,并把轮转后的新 token 原子写回 ~/.codex/auth.json。
最后脚本里两套续期逻辑,都按过期时间「门控」——只在 token 还剩不到 30 分钟时才续,平时不动。这样既不浪费 API 调用,又保证 headless 跑下去不会因为 token 过期挂掉:
# Claude:临近过期才调 CLI 续期def _ensure_claude_token(): o = _claude_oauth() or {} exp_ms = o.get("expiresAt") stale = (not o.get("accessToken")) or (exp_ms and exp_ms/1000 - time.time() < 1800) if stale: _refresh_claude_cli() # 一次最小的 claude -p o = _claude_oauth() or {} return o.get("accessToken")token 从头到尾不进我的上下文:Keychain 用 security 取、文件用 python 读,都只把值喂给后续命令,从不 echo 或打印。
又一个坑:macOS 不让 SSH 装 crontab
续期搞定,脚本在 mini 上手动跑通了,两个额度都 200。最后一步装定时任务:
ssh mac-mini '( crontab -l 2>/dev/null; echo "*/30 * * * * ..." ) | crontab -'# → crontab: tmp/tmp.33202: Operation not permittedOperation not permitted。这是 macOS 的老毛病:通过 SSH 装 crontab 会被 TCC 拦——cron 需要被授予 Full Disk Access 才能写它的 spool,而 SSH 会话默认没这个权限。本机 Terminal 装可能没事(授过权),但 headless SSH 装不了。
macOS 原生的、不吃这个限制的方案是 LaunchAgent。写一个 plist 放进 ~/Library/LaunchAgents/,用 StartInterval 定 1800 秒,launchctl bootstrap 加载:
<key>ProgramArguments</key><array> <string>/usr/bin/python3</string> <string>/Users/user/.config/displayservice/report_ai_usage.py</string></array><key>StartInterval</key><integer>1800</integer><key>RunAtLoad</key><true/><key>EnvironmentVariables</key><dict> <key>PATH</key><string>/Users/user/.local/bin:/opt/homebrew/bin:/usr/bin:/bin</string></dict>launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/app.report.plistlaunchctl list | grep report# 33230 0 app.report ← PID 在跑,上次退出码 0LaunchAgent 跑在用户上下文里,访问 ~/.claude、~/.codex、~/.config 这些 home 下的普通文件没问题(它们不是 TCC 保护的 ~/Documents、~/Desktop 那类)。RunAtLoad 让它一加载就先跑一次,省得等半小时。那个 PATH 要带上 homebrew(claude CLI 是个 node 程序,续期时要找 node)和 ~/.local/bin(claude 本体)。
验证
RunAtLoad 那次跑完,去服务端的 DB 看 ai_quota 表,确认 mini 推的数据确实落地了:
sqlite3 .../displayservice.db \ "SELECT source, w5h_used, w7d_used, (strftime('%s','now')-updated_at) AS age_sec FROM ai_quota;"# claude_code|9.0|6.0|18 ← 18 秒前,mini 刚写的# codex|1.0|7.0|18再拉一张服务端实时渲染的图,面板正确显示了 mini 来源的数据:CLAUDE 5h 91% / 1w 94%,CODEX 5h 99% / 1w 93%,带窗口重置倒计时。整条链路通了:mini 的 LaunchAgent(每 30 分钟)→ 打两个 usage 接口(顺带按需续 token)→ POST 给服务端 → 落 DB → 渲染。
经验总结
几个值得记下来的点:
-
要找一个 CLI 工具的数据源,先看它自己怎么拿。 Claude Code 的
/usage、Codex 的 rollout JSONL,背后都是这两个未公开的 OAuth 接口。用本机已登录的 token 直接打就能复现,不需要 API key。 -
订阅版的额度窗口是账号级全局的。 这点把上报架构从「必须在干活的机器上跑」松绑成「任何一台登录了同账号的机器都行」——于是可以挪到常开的 mini 上,而不依赖会睡眠的笔记本。
-
headless 定时任务最容易栽在 token 续期上。 access token 会过期,而一个只读文件的脚本不会触发 CLI 的无感续期。Claude 的刷新端点限流太凶不能直接打,就借
claude -p让 CLI 自己刷;Codex 的端点能直接打,就自己刷并写回。两套都按过期时间门控,不浪费也不掉链子。 -
macOS 上无人值守的定时任务用 LaunchAgent,别跟 cron 较劲。 SSH 装 crontab 会被 TCC 拦(
Operation not permitted),LaunchAgent 没这个限制,还是 macOS 原生推荐的方式。 -
凭据全程不进上下文。 Keychain 用
security取、文件用python读,值只喂给后续命令,从不打印——这是处理任何 secret 的底线。
这块墨水屏的迭代还在继续。从「显示用量」到「显示额度」,看似只是换了个数字,背后是一条完整的、能无人值守跑下去的数据管线。