diff --git a/scripts/agent-subscriptions b/scripts/agent-subscriptions index fd6da95..ae3c2b7 100755 --- a/scripts/agent-subscriptions +++ b/scripts/agent-subscriptions @@ -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). +def _anthropic_get(path: str, token: str) -> dict: + req = urllib.request.Request( + 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: + return json.load(resp) - Reset values are parsed as integer Unix epoch seconds; absent or malformed - headers yield no entry for that window. + +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] = {} - 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"} + 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: - 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 + 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 - - try: - body = json.dumps({ - "model": "claude-haiku-5-5", - "max_tokens": 1, - "messages": [{"role": "user", "content": "hi"}], - }).encode() - req = urllib.request.Request( - "https://api.anthropic.com/v1/messages", - data=body, - headers={**auth, "content-type": "application/json"}, - method="POST", - ) - 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 - 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__": diff --git a/specs/agent-subscriptions.spec.md b/specs/agent-subscriptions.spec.md index e944b40..3ae01ff 100644 --- a/specs/agent-subscriptions.spec.md +++ b/specs/agent-subscriptions.spec.md @@ -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 ` — 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 ` 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: (), renews ~ (in , 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