agent-subscriptions: use /api/oauth/usage and profile for Anthropic usage and plan info
Response rate-limit headers are no longer returned. Read the access token from the Claude Code profile credentials (user:profile scope), report 5h/7d usage and resets, and add plan info with an inferred monthly renewal. Claude-Session: https://claude.ai/code/session_01Gz3N3q53Bx9pbjB9tZT9dt
This commit is contained in:
@@ -10,18 +10,20 @@ import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ANTHROPIC_TOKEN_PATH = Path("~/dev/claude/secrets/anthropic/api_key").expanduser()
|
||||
# Interactive-login credentials (carry the user:profile scope that the usage
|
||||
# endpoint needs; the setup-token key in secrets/ does not). Read-only: never refreshed here.
|
||||
ANTHROPIC_PROFILE_DIR = Path(
|
||||
os.environ.get("AGENT_SUBSCRIPTIONS_CLAUDE_DIR", "~/.claude-oreillyit-anthropic")
|
||||
).expanduser()
|
||||
ANTHROPIC_TOKEN_PATH = ANTHROPIC_PROFILE_DIR / ".credentials.json"
|
||||
ANTHROPIC_API = "https://api.anthropic.com/api/oauth"
|
||||
MINIMAX_SOPS_PATH = Path(
|
||||
"~/dev/claude/projects/agent-runtime-secrets/providers/minimax/v1/provider.sops.env"
|
||||
).expanduser()
|
||||
MINIMAX_SOPS_KEY = Path("~/dev/claude/secrets/sops/provider-age-key.txt").expanduser()
|
||||
MINIMAX_DOTENV_KEY = "ANTHROPIC_AUTH_TOKEN"
|
||||
|
||||
# Reset field names. Anthropic emits Unix epoch seconds; MiniMax emits milliseconds.
|
||||
ANTHROPIC_RESET_HEADERS = {
|
||||
"five_hour": "anthropic-ratelimit-unified-5h-reset",
|
||||
"seven_day": "anthropic-ratelimit-unified-7d-reset",
|
||||
}
|
||||
# Reset field names. MiniMax emits milliseconds (Anthropic emits ISO 8601 strings).
|
||||
MINIMAX_RESET_FIELDS = {
|
||||
"five_hour": "end_time", # ms since epoch
|
||||
"seven_day": "weekly_end_time", # ms since epoch
|
||||
@@ -78,7 +80,11 @@ def _parse_dotenv_key(content: str, key: str) -> str:
|
||||
|
||||
|
||||
def _read_anthropic_token() -> str:
|
||||
return ANTHROPIC_TOKEN_PATH.read_text().strip()
|
||||
"""Access token from the Claude Code profile; raises if missing or expired."""
|
||||
oauth = json.loads(ANTHROPIC_TOKEN_PATH.read_text())["claudeAiOauth"]
|
||||
if oauth.get("expiresAt", 0) / 1000 < datetime.now(timezone.utc).timestamp():
|
||||
raise RuntimeError(f"access token expired; run claude once with CLAUDE_CONFIG_DIR={ANTHROPIC_PROFILE_DIR}")
|
||||
return oauth["accessToken"]
|
||||
|
||||
|
||||
def _read_minimax_key() -> str:
|
||||
@@ -90,75 +96,61 @@ def _read_minimax_key() -> str:
|
||||
return _parse_dotenv_key(result.stdout, MINIMAX_DOTENV_KEY)
|
||||
|
||||
|
||||
def _parse_anthropic_headers(headers) -> tuple[dict[str, float], dict[str, int]]:
|
||||
"""Returns (utilization_pct_by_window, reset_epoch_seconds_by_window).
|
||||
|
||||
Reset values are parsed as integer Unix epoch seconds; absent or malformed
|
||||
headers yield no entry for that window.
|
||||
"""
|
||||
util: dict[str, float] = {}
|
||||
reset: dict[str, int] = {}
|
||||
fh_u = headers.get("anthropic-ratelimit-unified-5h-utilization")
|
||||
sd_u = headers.get("anthropic-ratelimit-unified-7d-utilization")
|
||||
for key, raw in [("five_hour", fh_u), ("seven_day", sd_u)]:
|
||||
if raw is not None:
|
||||
try:
|
||||
util[key] = float(raw) * 100.0
|
||||
except ValueError:
|
||||
pass
|
||||
for key, hdr in ANTHROPIC_RESET_HEADERS.items():
|
||||
raw = headers.get(hdr)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
reset[key] = int(raw)
|
||||
except ValueError:
|
||||
pass
|
||||
return util, reset
|
||||
|
||||
|
||||
def probe_anthropic(token: str) -> tuple[dict[str, float], dict[str, int]] | None:
|
||||
"""Returns (utilization_pct, reset_epoch_seconds) or None on total failure.
|
||||
|
||||
Either dict may be empty; both are populated independently.
|
||||
"""
|
||||
auth = {"Authorization": f"Bearer {token}", "anthropic-version": "2023-06-01"}
|
||||
|
||||
try:
|
||||
req = urllib.request.Request("https://api.anthropic.com/v1/models", headers=auth)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
util, reset = _parse_anthropic_headers(resp.headers)
|
||||
if util or reset:
|
||||
return util, reset
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
body = json.dumps({
|
||||
"model": "claude-haiku-5-5",
|
||||
"max_tokens": 1,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}).encode()
|
||||
def _anthropic_get(path: str, token: str) -> dict:
|
||||
req = urllib.request.Request(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
data=body,
|
||||
headers={**auth, "content-type": "application/json"},
|
||||
method="POST",
|
||||
f"{ANTHROPIC_API}/{path}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
"User-Agent": "claude-code/2.1",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
util, reset = _parse_anthropic_headers(resp.headers)
|
||||
if util or reset:
|
||||
return util, reset
|
||||
return None
|
||||
except urllib.error.HTTPError as e:
|
||||
util, reset = _parse_anthropic_headers(e.headers)
|
||||
if util or reset:
|
||||
return util, reset
|
||||
return None
|
||||
return json.load(resp)
|
||||
|
||||
|
||||
def _next_monthly(anchor: datetime, now: datetime) -> datetime:
|
||||
"""Next monthly anniversary of anchor strictly after now (day clamped to 28)."""
|
||||
day = min(anchor.day, 28)
|
||||
y, m = now.year, now.month
|
||||
cand = anchor.replace(year=y, month=m, day=day)
|
||||
if cand <= now:
|
||||
y, m = (y + 1, 1) if m == 12 else (y, m + 1)
|
||||
cand = anchor.replace(year=y, month=m, day=day)
|
||||
return cand
|
||||
|
||||
|
||||
def probe_anthropic(token: str) -> tuple[dict[str, float], dict[str, int], dict]:
|
||||
"""Returns (utilization_pct, reset_epoch_seconds, subscription_info).
|
||||
|
||||
Raises on HTTP/network failure. The profile call is best-effort: a failure
|
||||
there yields an empty subscription dict.
|
||||
"""
|
||||
usage = _anthropic_get("usage", token)
|
||||
util: dict[str, float] = {}
|
||||
reset: dict[str, int] = {}
|
||||
for key in ("five_hour", "seven_day"):
|
||||
w = usage.get(key) or {}
|
||||
if w.get("utilization") is not None:
|
||||
util[key] = float(w["utilization"])
|
||||
if w.get("resets_at"):
|
||||
reset[key] = int(datetime.fromisoformat(w["resets_at"]).timestamp())
|
||||
|
||||
sub: dict = {}
|
||||
try:
|
||||
org = _anthropic_get("profile", token).get("organization", {})
|
||||
sub = {
|
||||
"plan": org.get("rate_limit_tier"),
|
||||
"status": org.get("subscription_status"),
|
||||
"started": org.get("subscription_created_at"),
|
||||
}
|
||||
if sub["started"]:
|
||||
started = datetime.fromisoformat(sub["started"].replace("Z", "+00:00"))
|
||||
# The API exposes no renewal date; assume a monthly cycle anchored on the start day.
|
||||
sub["next_renewal_inferred"] = _next_monthly(started, datetime.now(timezone.utc)).isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
return util, reset, sub
|
||||
|
||||
|
||||
def probe_minimax(api_key: str) -> tuple[dict[str, float], dict[str, int]] | tuple[None, str]:
|
||||
@@ -347,7 +339,7 @@ def print_dryrun(json_mode: bool = False) -> None:
|
||||
|
||||
print(f"{_BOLD}[dryrun] Would probe:{_RESET}")
|
||||
print(f" {_BOLD}Anthropic{_RESET} — {ANTHROPIC_TOKEN_PATH} (Bearer OAuth token)")
|
||||
print(f" GET https://api.anthropic.com/v1/models")
|
||||
print(f" GET {ANTHROPIC_API}/usage, {ANTHROPIC_API}/profile")
|
||||
print(f" {_BOLD}MiniMax{_RESET} — {MINIMAX_SOPS_PATH} (SOPS, key: {MINIMAX_DOTENV_KEY})")
|
||||
print(f" GET https://www.minimax.io/v1/token_plan/remains")
|
||||
|
||||
@@ -394,6 +386,7 @@ def _provider_record(
|
||||
reset: dict[str, int] | None,
|
||||
error: str | None,
|
||||
now_epoch: int,
|
||||
subscription: dict | None = None,
|
||||
) -> dict:
|
||||
if error is not None:
|
||||
return {"provider": name, "available": False, "error": error}
|
||||
@@ -402,7 +395,10 @@ def _provider_record(
|
||||
windows: dict[str, dict] = {}
|
||||
for key in ("five_hour", "seven_day"):
|
||||
windows[key] = _window_record(util or {}, reset or {}, key, now_epoch)
|
||||
return {"provider": name, "available": True, "windows": windows}
|
||||
rec = {"provider": name, "available": True, "windows": windows}
|
||||
if subscription:
|
||||
rec["subscription"] = subscription
|
||||
return rec
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -456,16 +452,14 @@ def main() -> None:
|
||||
|
||||
# Probe state: (utilization_pct dict, reset_epoch_seconds dict, error string)
|
||||
anth: tuple[dict | None, dict | None, str | None] = (None, None, None)
|
||||
anth_sub: dict = {}
|
||||
mini: tuple[dict | None, dict | None, str | None] = (None, None, None)
|
||||
|
||||
# Anthropic
|
||||
try:
|
||||
token = _read_anthropic_token()
|
||||
result = probe_anthropic(token)
|
||||
if result is None:
|
||||
anth = (None, None, "probe returned no data")
|
||||
else:
|
||||
anth = (result[0], result[1], None)
|
||||
a_util, a_reset, anth_sub = probe_anthropic(token)
|
||||
anth = (a_util, a_reset, None)
|
||||
except Exception as e:
|
||||
anth = (None, None, str(e))
|
||||
print(f"{_DIM}Warning: Anthropic probe failed: {e}{_RESET}", file=sys.stderr)
|
||||
@@ -489,7 +483,7 @@ def main() -> None:
|
||||
|
||||
if json_mode:
|
||||
report = build_json_report([
|
||||
_provider_record("Anthropic", anth[0], anth[1], anth[2], now_epoch),
|
||||
_provider_record("Anthropic", anth[0], anth[1], anth[2], now_epoch, anth_sub),
|
||||
_provider_record("MiniMax", mini[0], mini[1], mini[2], now_epoch),
|
||||
])
|
||||
print(json.dumps(report, indent=2))
|
||||
@@ -519,6 +513,13 @@ def main() -> None:
|
||||
add_rows("MiniMax", mini[0], mini[1], mini[2])
|
||||
|
||||
print_table(rows)
|
||||
if anth_sub:
|
||||
renew = anth_sub.get("next_renewal_inferred")
|
||||
when = ""
|
||||
if renew:
|
||||
secs = int(datetime.fromisoformat(renew).timestamp()) - now_epoch
|
||||
when = f", renews ~{renew[:10]} (in {_format_relative(secs)}, inferred)"
|
||||
print(f"Anthropic plan: {anth_sub.get('plan')} ({anth_sub.get('status')}){when}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -19,13 +19,10 @@ The default mode (no flag) prints a coloured table suitable for terminals.
|
||||
|
||||
## Behaviour
|
||||
|
||||
1. Read the Anthropic OAuth token from `~/dev/claude/secrets/anthropic/api_key` (whole-file Bearer token).
|
||||
2. Probe Anthropic subscription usage and reset times:
|
||||
- First attempt: `GET https://api.anthropic.com/v1/models` with `Authorization: Bearer <token>` — zero token cost.
|
||||
- Read `anthropic-ratelimit-unified-5h-utilization` and `anthropic-ratelimit-unified-7d-utilization` (fractions 0.0–1.0; multiply by 100 for percentage).
|
||||
- Read `anthropic-ratelimit-unified-5h-reset` and `anthropic-ratelimit-unified-7d-reset`. Values are **Unix epoch seconds** (integer string).
|
||||
- Source: https://platform.claude.com/docs/en/api/rate-limits and https://github.com/anthropics/claude-code/issues/12829 (example values `1764554400`, `1764615600`).
|
||||
- If headers absent, fall back: `POST https://api.anthropic.com/v1/messages` with model `claude-haiku-5-5`, `max_tokens=1`, message `"hi"` — same headers on response (or on the HTTPError if 429).
|
||||
1. Read the Anthropic OAuth access token from `claudeAiOauth.accessToken` in `$AGENT_SUBSCRIPTIONS_CLAUDE_DIR/.credentials.json` (default `~/.claude-oreillyit-anthropic`). This is the interactive-login credential, which carries the `user:profile` scope; the setup-token key in `secrets/anthropic/api_key` does not and gets 403 `oauth_scope_insufficient`. The file is read-only here: the script never refreshes it. If `expiresAt` has passed, Anthropic is reported unavailable with a "run claude once" hint.
|
||||
2. Probe Anthropic with `anthropic-beta: oauth-2025-04-20`:
|
||||
- `GET https://api.anthropic.com/api/oauth/usage` returns `five_hour` and `seven_day` objects with `utilization` (percent, 0-100) and `resets_at` (ISO 8601 string). The old `anthropic-ratelimit-unified-*` response headers are no longer returned and are not used.
|
||||
- `GET https://api.anthropic.com/api/oauth/profile` (best-effort) returns `organization.rate_limit_tier`, `subscription_status`, `subscription_created_at`. No renewal date is exposed, so `next_renewal_inferred` assumes a monthly cycle on the day-of-month of `subscription_created_at` (day clamped to 28).
|
||||
3. Read the MiniMax API key from `~/dev/claude/projects/agent-runtime-secrets/providers/minimax/v1/provider.sops.env` via `sops --decrypt --output-type dotenv` (key: `ANTHROPIC_AUTH_TOKEN`). Uses `SOPS_AGE_KEY_FILE=~/dev/claude/secrets/sops/provider-age-key.txt`.
|
||||
4. Probe MiniMax subscription usage and reset times:
|
||||
- `GET https://www.minimax.io/v1/token_plan/remains` with `Authorization: Bearer <key>` and `User-Agent: curl/7.88.1` (minimax.io blocks Python-urllib default UA).
|
||||
@@ -47,12 +44,12 @@ The default mode (no flag) prints a coloured table suitable for terminals.
|
||||
|
||||
| Provider | Field | Format |
|
||||
|-----------|---------------------------------------------|---------------------------|
|
||||
| Anthropic | `anthropic-ratelimit-unified-5h-reset` | Unix epoch seconds (int) |
|
||||
| Anthropic | `anthropic-ratelimit-unified-7d-reset` | Unix epoch seconds (int) |
|
||||
| Anthropic | `usage.five_hour.resets_at` | ISO 8601 string |
|
||||
| Anthropic | `usage.seven_day.resets_at` | ISO 8601 string |
|
||||
| MiniMax | `model_remains[general].end_time` | Unix epoch **milliseconds** |
|
||||
| MiniMax | `model_remains[general].weekly_end_time` | Unix epoch **milliseconds** |
|
||||
|
||||
Note the unit difference: Anthropic emits seconds; MiniMax emits milliseconds. The implementation normalises both to seconds.
|
||||
Note the format difference: Anthropic emits ISO strings; MiniMax emits milliseconds. The implementation normalises both to seconds.
|
||||
|
||||
If a reset field is absent (header missing on the Anthropic side, or the v1 fallback path on the MiniMax side), the reset cell in the table shows `UNKNOWN` and the JSON field `reset_at` is set to `null` with `reset_in_seconds: null`. `reset_in_seconds` is computed as `reset_at - now` at probe time.
|
||||
|
||||
@@ -64,8 +61,8 @@ Default (no `--output`):
|
||||
|
||||
```
|
||||
[dryrun] Would probe:
|
||||
Anthropic — ~/dev/claude/secrets/anthropic/api_key (Bearer OAuth token)
|
||||
GET https://api.anthropic.com/v1/models
|
||||
Anthropic — ~/.claude-oreillyit-anthropic/.credentials.json (Bearer OAuth token)
|
||||
GET https://api.anthropic.com/api/oauth/usage, /api/oauth/profile
|
||||
MiniMax — ~/dev/claude/projects/agent-runtime-secrets/providers/minimax/v1/provider.sops.env (SOPS)
|
||||
GET https://www.minimax.io/v1/token_plan/remains
|
||||
```
|
||||
@@ -122,6 +119,10 @@ With `--output json` (or `--dryrun --output json`):
|
||||
|
||||
Note: `probed_at` and the reset values are placeholders — the dryrun output does not depend on the actual wall clock, only on the structure. `window_seconds` is always the fixed constant for that window key (never a placeholder); `elapsed_pct` is derived from `reset_in_seconds` and `window_seconds` so the sample stays internally consistent (shown rounded to 4 decimal places above; the implementation does not round).
|
||||
|
||||
## Subscription info (Anthropic)
|
||||
|
||||
Default output prints a line below the table: `Anthropic plan: <tier> (<status>), renews ~<date> (in <rel>, inferred)`. JSON adds an optional `subscription` object to the Anthropic provider entry: `plan`, `status`, `started`, `next_renewal_inferred` (ISO 8601). The object is omitted when the profile call fails. The renewal date is inferred, not authoritative.
|
||||
|
||||
## Output format
|
||||
|
||||
### Default table
|
||||
|
||||
Reference in New Issue
Block a user