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:
155
claude_usage.py
Normal file
155
claude_usage.py
Normal 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 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))
|
||||
Reference in New Issue
Block a user