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

62
usage_common.py Normal file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
用量采集的公共骨架 —— 本地缓存 + 有效期TTL+ 失败退避Claude / Codex 两个采集器共用)。
为什么需要launchd 每 5 分钟起两个独立进程(小屏渲染 + 上报),且用户可能手动连跑几次;
账户级用量接口对突发调用敏感Anthropic 的 /api/oauth/usage 一分钟内几十次即 429 并封约 24 小时,
连带 Claude Code `/usage` 与 claude.ai 一起看不到用量)。故:
- 距上次成功拉取不足 TTL 秒CLAUDE_USAGE_TTL / CODEX_USAGE_TTL语义同 WEATHER_TTL/MAO_TTL/BTC_TTL0=每次实拉)→ 直接用缓存,不发请求;
- 拉取失败 → 沿用旧缓存(标 stale并按 BACKOFF 秒退避,退避期内不再尝试;异常对象带 `backoff` 属性时按它退避
claude_usage.TokenUnavailable = 一个 TTLtoken 过期是本机状态、Claude Code 随时可能刷新,不必等满 BACKOFF
- 从未成功且本次失败 → 抛出,由上层回退占位 "--"
缓存文件只存快照与时间戳,**不存任何凭证**。
"""
import json
import os
import time
def cached_snapshot(path, ttl, backoff, fetch, force=False):
"""返回 (snapshot, status)。status ∈ {"fetched", "cache", "stale", "backoff"}。
fetch() 需返回可 JSON 序列化的快照 dict含 updated_at"""
state = _load(path)
now = time.time()
snap = state.get("snapshot")
if not force and snap and now - state.get("fetched_at", 0) < ttl:
return snap, "cache"
if not force and state.get("backoff_until", 0) > now:
if snap:
return snap, "backoff"
raise RuntimeError(f"退避期内(至 {time.strftime('%H:%M:%S', time.localtime(state['backoff_until']))}),且无旧快照")
try:
snap_new = fetch()
except Exception as e:
state["backoff_until"] = now + getattr(e, "backoff", backoff) # 异常可自带更短退避(如本机 token 过期,只需等一个 TTL
state["last_error"] = f"{type(e).__name__}: {e}"[:300]
_save(path, state)
if snap:
print(f"[用量] 拉取失败,沿用旧快照:{e}")
return snap, "stale"
raise
state = {"fetched_at": now, "snapshot": snap_new}
_save(path, state)
return snap_new, "fetched"
def _load(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def _save(path, state):
tmp = path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False)
os.replace(tmp, path)
except Exception as e: # 缓存写不进去不算致命
print(f"[用量] 缓存写入失败:{e}")