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:
2026-09-14 14:08:21 +08:00
parent 2015947812
commit 7378d6bb88
15 changed files with 561 additions and 108 deletions

155
claude_usage.py Normal file
View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Claude 账户级用量 —— 复用 Claude Code 自身的 OAuth 登录态,直接调 Anthropic 的 /api/oauth/usage。
这是 Claude Code `/usage` 命令、claude.ai 用量页共用的同一数据源,反映**整个订阅账户**的 5 小时 / 7 天 /
模型限定 7 天额度:不论额度是在 Claude Code CLI、桌面 App 还是网页消耗的,这里都能看到。
与旧方案statusline 插件把 Claude Code stdin 里的 rate_limits 落盘、本项目读文件)相比:
不依赖 Claude Code 进程在跑、不依赖任何第三方插件,空闲时也持续刷新。
鉴权复用方式与 statusline 一致——**只读** Claude Code 存下来的 access token
1. 环境变量 CLAUDE_CODE_OAUTH_TOKEN
2. macOS 登录钥匙串 generic passwordservice「Claude Code-credentials」JSON 的 claudeAiOauth.accessToken
3. 文件 ~/.claude/.credentials.jsonLinux / SSH 场景;尊重 CLAUDE_CONFIG_DIR
铁律:
- **绝不刷新 token**。refresh token 每次使用即轮换,第三方刷新而不写回会让 Claude Code 下次 401 并强制重新登录。
access token 约 8 小时有效,过期就沿用旧快照(标 stale等 Claude Code 自己下次启动刷新。
- **快照有效期 CLAUDE_USAGE_TTL 秒内不再调**.env 可调,默认 3000=每次实拉429 后退避(见 usage_common
- 必须与 Claude Code 在同一台机器、同一用户会话下运行(读钥匙串)——所以在形态 A 里它跑在本机,由
main.py --target push 上报给 server.py服务端不持任何凭证。
输出快照结构(与旧 statusline 快照兼容data.usage_from_raw 直接消费):
{five_hour:{utilization, resets_at}, seven_day:{...}, seven_day_fable:{...}|None, updated_at, source}
"""
import json
import os
import subprocess
import time
import urllib.error
import urllib.request
import config
import usage_common
API_URL = "https://api.anthropic.com/api/oauth/usage"
KEYCHAIN_SERVICE = "Claude Code-credentials"
TIMEOUT = 15
class TokenUnavailable(RuntimeError):
"""找不到 token 或已过期——本机状态而非接口限流:退避只需一个 TTLClaude Code 随时可能刷新钥匙串),不按 BACKOFF 长等。"""
backoff = config.CLAUDE_USAGE_TTL
# ---------------------------------------------------------------- 凭证(只读)
def _credentials_blob():
"""按优先级取 claudeAiOauth JSON 串;找不到返回 None。"""
env_tok = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
if env_tok:
return json.dumps({"claudeAiOauth": {"accessToken": env_tok}})
try:
out = subprocess.run(["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"],
capture_output=True, text=True, timeout=5)
if out.returncode == 0 and out.stdout.strip():
return out.stdout.strip()
except (OSError, subprocess.TimeoutExpired):
pass
cfg_dir = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude")
path = os.path.join(cfg_dir, ".credentials.json")
if os.path.exists(path):
with open(path, encoding="utf-8") as f:
return f.read()
return None
def read_token():
"""返回未过期的 access token拿不到/已过期抛 TokenUnavailable。"""
blob = _credentials_blob()
if not blob:
raise TokenUnavailable("未找到 Claude Code 登录凭证钥匙串「Claude Code-credentials」/ ~/.claude/.credentials.json")
try:
oauth = json.loads(blob).get("claudeAiOauth") or {}
except json.JSONDecodeError as e:
raise TokenUnavailable(f"凭证 JSON 解析失败:{e}") from e
tok = oauth.get("accessToken")
if not tok:
raise TokenUnavailable("凭证里没有 accessToken")
exp = oauth.get("expiresAt") # 毫秒 epoch缺失则不判过期
if exp and float(exp) / 1000 <= time.time() + 30:
raise TokenUnavailable("Claude Code 的 access token 已过期(约 8 小时有效),等 Claude Code 下次启动自动刷新;本模块不做刷新")
return tok
# ---------------------------------------------------------------- 拉取与归一
def fetch_raw():
"""GET /api/oauth/usage返回原始 JSON。直连、不走代理与本机其他脚本一致"""
req = urllib.request.Request(API_URL, headers={
"Authorization": f"Bearer {read_token()}",
"anthropic-beta": "oauth-2025-04-20",
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": config.CLAUDE_USAGE_UA,
})
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
try:
with opener.open(req, timeout=TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "replace")[:200]
if e.code == 429:
raise RuntimeError(f"HTTP 429 被限流(将退避 {config.CLAUDE_USAGE_BACKOFF}s勿高频调用否则约 24 小时内连 Claude Code /usage 也看不到):{body}") from e
if e.code == 401:
raise TokenUnavailable(f"HTTP 401 token 无效:{body}") from e
raise RuntimeError(f"HTTP {e.code}{body}") from e
def _seg(percent, resets_at):
if percent is None:
return None
return {"utilization": float(percent), "resets_at": resets_at}
def normalize(raw, now=None):
"""原始响应 → 快照。优先用 limits[]2026 年中起模型限定额度只在这里kind=weekly_scoped + scope.model.display_name
顶层 five_hour / seven_day 作兜底;两处都没有的窗口为 None。"""
now = now or time.time()
five = seven = scoped = None
for lim in raw.get("limits") or []:
kind = lim.get("kind")
seg = _seg(lim.get("percent"), lim.get("resets_at"))
if kind == "session":
five = five or seg
elif kind == "weekly_all":
seven = seven or seg
elif kind == "weekly_scoped":
name = ((lim.get("scope") or {}).get("model") or {}).get("display_name") or ""
if name.lower() == config.CLAUDE_USAGE_SCOPE_MODEL.lower():
scoped = scoped or seg
for key, cur in (("five_hour", five), ("seven_day", seven)):
if cur is None and isinstance(raw.get(key), dict):
cur = _seg(raw[key].get("utilization"), raw[key].get("resets_at"))
if key == "five_hour":
five = cur
else:
seven = cur
if scoped is None and isinstance(raw.get(config.CLAUDE_USAGE_SCOPE_FALLBACK_KEY), dict):
s = raw[config.CLAUDE_USAGE_SCOPE_FALLBACK_KEY]
scoped = _seg(s.get("utilization"), s.get("resets_at"))
return {"five_hour": five, "seven_day": seven, "seven_day_fable": scoped,
"updated_at": int(now), "source": "oauth-usage"}
def get_usage(force=False):
"""对外入口:带缓存/最小间隔/退避的账户级用量快照(结构见模块说明)。失败且无旧快照时抛出。"""
snap, status = usage_common.cached_snapshot(
config.CLAUDE_USAGE_CACHE_PATH, config.CLAUDE_USAGE_TTL, config.CLAUDE_USAGE_BACKOFF,
lambda: normalize(fetch_raw()), force=force)
snap = dict(snap)
snap["status"] = status
return snap
if __name__ == "__main__":
import sys
d = get_usage(force="--force" in sys.argv)
print(json.dumps(d, ensure_ascii=False, indent=1))