- 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>
156 lines
7.4 KiB
Python
156 lines
7.4 KiB
Python
#!/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 password,service「Claude Code-credentials」,JSON 的 claudeAiOauth.accessToken;
|
||
3. 文件 ~/.claude/.credentials.json(Linux / SSH 场景;尊重 CLAUDE_CONFIG_DIR)。
|
||
铁律:
|
||
- **绝不刷新 token**。refresh token 每次使用即轮换,第三方刷新而不写回会让 Claude Code 下次 401 并强制重新登录。
|
||
access token 约 8 小时有效,过期就沿用旧快照(标 stale),等 Claude Code 自己下次启动刷新。
|
||
- **快照有效期 CLAUDE_USAGE_TTL 秒内不再调**(.env 可调,默认 300;0=每次实拉),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 或已过期——本机状态而非接口限流:退避只需一个 TTL(Claude 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))
|