2570 字
13 分钟
给墨水屏加一块「AI 额度」面板:逆向 Claude Code 与 Codex 的用量接口,以及无人值守的 token 续期

上一篇把 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 CodeGET https://api.anthropic.com/api/oauth/usage

token 在 macOS Keychain 里(服务名 Claude Code-credentials),或者 Linux 的 ~/.claude/.credentials.jsonclaudeAiOauth.accessToken。我用 shell 把 token 取出来直接打,token 全程不进上下文:

Terminal window
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 - utilizationresets_at 是 ISO 8601 的窗口重置时间。

User-Agent 不是装饰

那个 User-Agent: claude-code/<version> 必须带。少了它,请求会落进一个被狠狠限流的桶里,持续返回 429,而且没有 Retry-After,退避也救不回来。带上正常的 UA,30 秒一次的轮询完全安全。

CodexGET https://chatgpt.com/backend-api/wham/usage

token 在 ~/.codex/auth.jsontokens.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_windowlimit_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 · LIMITS
CLAUDE
5h ▓▓▓▓▓▓▓▓▓░ 91% ↺1h
1w ▓▓▓▓▓▓▓▓▓░ 94% ↺3d
CODEX
5h ▓▓▓▓▓▓▓▓▓▓ 99% ↺4h
1w ▓▓▓▓▓▓▓▓▓░ 93% ↺3d

倒计时用窗口的 reset_at 减当前时间,按量级显示 45m / 2h / 3d。一个小细节:倒计时要用「画面里的 now」算,而不是渲染时的墙钟——服务端预览用的是固定的样例时间戳,如果用墙钟算,样例图的倒计时全是 0m。所以把 now_epochScreenData.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 早过期了:

Terminal window
# 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 端点:

Terminal window
# 直接刷 Claude token
curl -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 有没有更新:

Terminal window
claude -p "reply ok" # → ok
# 之后 expiresAt 从 -34.5h 变成了 +8.0h

成了。所以 Claude 的续期策略定下来:claude CLI 自己刷,脚本只在 token 临近过期时调一次极小的 claude -p

Codex 这边反而更顺。它的 access token 是 JWT,解出 exp 看寿命:

Terminal window
# 解 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。最后一步装定时任务:

Terminal window
ssh mac-mini '( crontab -l 2>/dev/null; echo "*/30 * * * * ..." ) | crontab -'
# → crontab: tmp/tmp.33202: Operation not permitted

Operation not permitted。这是 macOS 的老毛病:通过 SSH 装 crontab 会被 TCC 拦——cron 需要被授予 Full Disk Access 才能写它的 spool,而 SSH 会话默认没这个权限。本机 Terminal 装可能没事(授过权),但 headless SSH 装不了。

macOS 原生的、不吃这个限制的方案是 LaunchAgent。写一个 plist 放进 ~/Library/LaunchAgents/,用 StartInterval 定 1800 秒,launchctl bootstrap 加载:

~/Library/LaunchAgents/app.report.plist
<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>
Terminal window
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/app.report.plist
launchctl list | grep report
# 33230 0 app.report ← PID 在跑,上次退出码 0

LaunchAgent 跑在用户上下文里,访问 ~/.claude~/.codex~/.config 这些 home 下的普通文件没问题(它们不是 TCC 保护的 ~/Documents~/Desktop 那类)。RunAtLoad 让它一加载就先跑一次,省得等半小时。那个 PATH 要带上 homebrew(claude CLI 是个 node 程序,续期时要找 node)和 ~/.local/binclaude 本体)。

验证#

RunAtLoad 那次跑完,去服务端的 DB 看 ai_quota 表,确认 mini 推的数据确实落地了:

Terminal window
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 的底线。

这块墨水屏的迭代还在继续。从「显示用量」到「显示额度」,看似只是换了个数字,背后是一条完整的、能无人值守跑下去的数据管线。

给墨水屏加一块「AI 额度」面板:逆向 Claude Code 与 Codex 的用量接口,以及无人值守的 token 续期
https://blog.lishuyu.app/posts/trmnl墨水屏ai额度监控面板/
作者
猫猫魔女
发布于
2026-06-14
许可协议
CC BY-NC-SA 4.0