Files
eink-push/claude_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

156 lines
7.4 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 -*-
"""
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))