#!/usr/bin/env python3 """Show live subscription usage for each AI provider used by agent runtimes.""" import json import os import subprocess import sys import urllib.error import urllib.request from datetime import datetime, timezone from pathlib import Path ANTHROPIC_TOKEN_PATH = Path("~/dev/claude/secrets/anthropic/api_key").expanduser() 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", } MINIMAX_RESET_FIELDS = { "five_hour": "end_time", # ms since epoch "seven_day": "weekly_end_time", # ms since epoch } # Nominal window lengths in seconds, used to compute elapsed_pct. These are # fixed constants, not queried at runtime: Anthropic documents 5h/7d unified # rate-limit windows (https://platform.claude.com/docs/en/api/rate-limits), # and MiniMax v2's interval/weekly reset fields (end_time / weekly_end_time) # are consistent with the same cadence. Neither API exposes an authoritative # "window length" value to verify against, so these are assumed nominal # lengths rather than confirmed at runtime. WINDOW_SECONDS = { "five_hour": 18000, # 5 * 3600 "seven_day": 604800, # 7 * 86400 } # ANSI colour support _use_colour = sys.stdout.isatty() _GREEN = "\033[32m" if _use_colour else "" _YELLOW = "\033[33m" if _use_colour else "" _RED = "\033[31m" if _use_colour else "" _RESET = "\033[0m" if _use_colour else "" _BOLD = "\033[1m" if _use_colour else "" _DIM = "\033[2m" if _use_colour else "" def _colour_pct(pct: float) -> str: s = f"{pct:5.1f}%" if pct >= 80: return f"{_RED}{s}{_RESET}" if pct >= 60: return f"{_YELLOW}{s}{_RESET}" return f"{_GREEN}{s}{_RESET}" def _parse_dotenv_key(content: str, key: str) -> str: for line in content.splitlines(): line = line.strip() if not line or line.startswith("#"): continue if "=" in line: k, _, v = line.partition("=") elif ": " in line: k, _, v = line.partition(": ") else: continue if k.strip() == key: v = v.strip() if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"): v = v[1:-1] return v raise KeyError(f"Key '{key}' not found in decrypted content") def _read_anthropic_token() -> str: return ANTHROPIC_TOKEN_PATH.read_text().strip() def _read_minimax_key() -> str: env = {**os.environ, "SOPS_AGE_KEY_FILE": str(MINIMAX_SOPS_KEY)} result = subprocess.run( ["sops", "--decrypt", "--output-type", "dotenv", str(MINIMAX_SOPS_PATH)], capture_output=True, text=True, check=True, env=env, ) 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-4-5-20251001", "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 def probe_minimax(api_key: str) -> tuple[dict[str, float], dict[str, int]] | tuple[None, str]: """Returns ((utilization_pct, reset_epoch_seconds), None) on success, or (None, error_reason) on failure. API v2 shape (current): model_remains[].model_name with remaining_percent fields and end_time/weekly_end_time in **milliseconds**. API v1 shape (legacy): category_remains[].category == "text_generation" with count fields only — no reset timestamps. Tries v2 first; falls back to v1. """ try: req = urllib.request.Request( "https://www.minimax.io/v1/token_plan/remains", headers={ "Authorization": f"Bearer {api_key}", "User-Agent": "curl/7.88.1", }, ) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read()) status = data.get("base_resp", {}).get("status_code") if status != 0: msg = data.get("base_resp", {}).get("status_msg", "unknown") return None, f"API status_code={status} ({msg})" # v2: model_remains with remaining_percent fields (general = text) for entry in data.get("model_remains", []): if entry.get("model_name") == "general": util: dict[str, float] = {} reset: dict[str, int] = {} ih = entry.get("current_interval_remaining_percent") wh = entry.get("current_weekly_remaining_percent") if ih is not None: util["five_hour"] = 100.0 - float(ih) if wh is not None: util["seven_day"] = 100.0 - float(wh) # Reset fields are epoch milliseconds; normalise to seconds. end_ms = entry.get("end_time") week_ms = entry.get("weekly_end_time") if isinstance(end_ms, (int, float)) and end_ms > 0: reset["five_hour"] = int(end_ms) // 1000 if isinstance(week_ms, (int, float)) and week_ms > 0: reset["seven_day"] = int(week_ms) // 1000 if util or reset: return util, reset return None, "general model entry has no usable fields" # v1 fallback: category_remains with count fields — no reset timestamps. for cat in data.get("category_remains", []): if cat.get("category") == "text_generation": util = {} interval_total = cat.get("current_interval_total_count", 0) interval_used = cat.get("current_interval_usage_count", 0) weekly_total = cat.get("current_weekly_total_count", 0) weekly_used = cat.get("current_weekly_usage_count", 0) if interval_total > 0: util["five_hour"] = float(interval_used) / float(interval_total) * 100.0 if weekly_total > 0: util["seven_day"] = float(weekly_used) / float(weekly_total) * 100.0 if util: return util, {} # no reset fields in v1 return None, "category_remains has zero counts" return None, "no usable plan data in response (keys: " + ", ".join(data.keys()) + ")" except urllib.error.HTTPError as e: return None, f"HTTP {e.code} {e.reason}" except urllib.error.URLError as e: return None, f"network error: {e.reason}" except Exception as e: return None, str(e) def _format_relative(seconds: int | None) -> str: """Human-readable relative time, e.g. '6d 8h', '4h 37m', '12m', '45s', 'UNKNOWN'.""" if seconds is None: return "UNKNOWN" if seconds < 0: seconds = 0 days, rem = divmod(seconds, 86400) hours, rem = divmod(rem, 3600) minutes, secs = divmod(rem, 60) if days > 0: return f"{days}d {hours}h" if hours > 0: return f"{hours}h {minutes}m" if minutes > 0: return f"{minutes}m" return f"{secs}s" def _iso_from_epoch(seconds: int | None) -> str | None: if seconds is None: return None return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat() def _iso_now(now: datetime | None = None) -> str: return (now or datetime.now(timezone.utc)).isoformat() def _reset_in_seconds(reset_epoch: int | None, now_epoch: int | None = None) -> int | None: """Seconds from now until reset_epoch, clamped to 0 (never negative). A negative raw delta means the reset already fired (clock skew between probe time and the provider's clock, or a probe that lands right on the boundary) -- clamp at the source so every downstream consumer sees a consistent floor instead of re-implementing the clamp themselves. This also pins elapsed_pct (below) at 1.0 in that case. """ if reset_epoch is None: return None now = now_epoch if now_epoch is not None else int(datetime.now(timezone.utc).timestamp()) return max(0, reset_epoch - now) def _elapsed_pct(reset_in_seconds: int | None, window_key: str) -> float | None: """Fraction of the window already elapsed: 1 - reset_in_seconds/window_seconds. Clamped to [0.0, 1.0]. Returns None (fail-closed) when reset_in_seconds is None -- i.e. no reset timestamp is available for this window (the MiniMax v1 fallback path returns utilization but no reset fields). Consumers must treat None as "cannot pace this window", never as 0.0 or 1.0. """ if reset_in_seconds is None: return None window_seconds = WINDOW_SECONDS[window_key] pct = 1.0 - (reset_in_seconds / window_seconds) return max(0.0, min(1.0, pct)) def print_dryrun(json_mode: bool = False) -> None: if json_mode: # Sample reset_in_seconds; elapsed_pct is derived from these via the # real _elapsed_pct() so the sample stays consistent with live output. fh_reset_in_seconds = 16620 sd_reset_in_seconds = 538200 sample = { "probed_at": "2026-08-01T14:23:00+00:00", "providers": [ { "provider": "Anthropic", "available": True, "windows": { "five_hour": { "utilization_pct": 23.4, "reset_at": "2026-08-01T19:00:00+00:00", "reset_in_seconds": fh_reset_in_seconds, "window_seconds": WINDOW_SECONDS["five_hour"], "elapsed_pct": _elapsed_pct(fh_reset_in_seconds, "five_hour"), }, "seven_day": { "utilization_pct": 41.2, "reset_at": "2026-08-07T23:00:00+00:00", "reset_in_seconds": sd_reset_in_seconds, "window_seconds": WINDOW_SECONDS["seven_day"], "elapsed_pct": _elapsed_pct(sd_reset_in_seconds, "seven_day"), }, }, }, { "provider": "MiniMax", "available": True, "windows": { "five_hour": { "utilization_pct": 12.1, "reset_at": "2026-08-01T19:00:00+00:00", "reset_in_seconds": fh_reset_in_seconds, "window_seconds": WINDOW_SECONDS["five_hour"], "elapsed_pct": _elapsed_pct(fh_reset_in_seconds, "five_hour"), }, "seven_day": { "utilization_pct": 8.3, "reset_at": "2026-08-07T23:00:00+00:00", "reset_in_seconds": sd_reset_in_seconds, "window_seconds": WINDOW_SECONDS["seven_day"], "elapsed_pct": _elapsed_pct(sd_reset_in_seconds, "seven_day"), }, }, }, ], } print(json.dumps(sample, indent=2)) return 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" {_BOLD}MiniMax{_RESET} — {MINIMAX_SOPS_PATH} (SOPS, key: {MINIMAX_DOTENV_KEY})") print(f" GET https://www.minimax.io/v1/token_plan/remains") def print_table( rows: list[tuple[str, str, str, str]], col_widths: tuple[int, int, int, int] = (12, 10, 8, 12), ) -> None: print(f"\n{_BOLD}Agent Subscription Usage{_RESET}") print("========================\n") c1, c2, c3, c4 = col_widths header = f"{'Provider':<{c1}} {'Window':<{c2}} {'Usage':<{c3}} {'Resets':<{c4}}" sep = f"{'-'*c1} {'-'*c2} {'-'*c3} {'-'*c4}" print(f"{_BOLD}{header}{_RESET}") print(sep) for provider, window, usage, resets in rows: print(f"{provider:<{c1}} {window:<{c2}} {usage:<{c3}} {resets:<{c4}}") print() def _window_record(util: dict[str, float], reset: dict[str, int], key: str, now_epoch: int) -> dict: pct = util.get(key) reset_epoch = reset.get(key) reset_in_seconds = _reset_in_seconds(reset_epoch, now_epoch) return { "utilization_pct": pct, "reset_at": _iso_from_epoch(reset_epoch), "reset_in_seconds": reset_in_seconds, "window_seconds": WINDOW_SECONDS[key], "elapsed_pct": _elapsed_pct(reset_in_seconds, key), } def build_json_report(providers: list[dict]) -> dict: return { "probed_at": _iso_now(), "providers": providers, } def _provider_record( name: str, util: dict[str, float] | None, reset: dict[str, int] | None, error: str | None, now_epoch: int, ) -> dict: if error is not None: return {"provider": name, "available": False, "error": error} if util is None and reset is None: return {"provider": name, "available": False, "error": "no data returned"} 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} def main() -> None: dryrun = False json_mode = False args = sys.argv[1:] i = 0 while i < len(args): arg = args[i] if arg in ("-h", "--help"): print(__doc__) print("Usage: agent-subscriptions [OPTIONS]") print() print("Options:") print(" -n, --dryrun Preview probes without making API calls") print(" -j, --output json Emit structured JSON (plain text, no ANSI)") print(" -h, --help Show this help message and exit") sys.exit(0) elif arg in ("-n", "--dryrun"): dryrun = True i += 1 elif arg == "-j": json_mode = True i += 1 elif arg == "--output": if i + 1 >= len(args): print("Error: --output requires a value (json)", file=sys.stderr) sys.exit(1) value = args[i + 1] if value != "json": print(f"Error: unsupported --output value: {value}", file=sys.stderr) sys.exit(1) json_mode = True i += 2 elif arg.startswith("--output="): value = arg.split("=", 1)[1] if value != "json": print(f"Error: unsupported --output value: {value}", file=sys.stderr) sys.exit(1) json_mode = True i += 1 else: print(f"Error: unknown option: {arg}", file=sys.stderr) sys.exit(1) if dryrun: print_dryrun(json_mode=json_mode) return now_epoch = int(datetime.now(timezone.utc).timestamp()) # Probe state: (utilization_pct dict, reset_epoch_seconds dict, error string) anth: tuple[dict | None, dict | None, str | None] = (None, None, None) 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) except Exception as e: anth = (None, None, str(e)) print(f"{_DIM}Warning: Anthropic probe failed: {e}{_RESET}", file=sys.stderr) # MiniMax try: key = _read_minimax_key() result = probe_minimax(key) if isinstance(result[1], str) and result[0] is None and not result[1]: mini = (None, None, "probe returned no data") elif result[1] is not None and not isinstance(result[1], dict): # error branch: (None, error_str) mini = (None, None, result[1]) print(f"{_DIM}Warning: MiniMax probe: {result[1]}{_RESET}", file=sys.stderr) else: util, reset = result mini = (util, reset, None) except Exception as e: mini = (None, None, str(e)) print(f"{_DIM}Warning: MiniMax probe failed: {e}{_RESET}", file=sys.stderr) if json_mode: report = build_json_report([ _provider_record("Anthropic", anth[0], anth[1], anth[2], now_epoch), _provider_record("MiniMax", mini[0], mini[1], mini[2], now_epoch), ]) print(json.dumps(report, indent=2)) return rows: list[tuple[str, str, str, str]] = [] def add_rows(name: str, util: dict | None, reset: dict | None, error: str | None) -> None: if error is not None or (util is None and reset is None): rows.append((name, "5-hour", f"{_DIM}UNAVAILABLE{_RESET}", f"{_DIM}UNKNOWN{_RESET}")) rows.append((name, "7-day", f"{_DIM}UNAVAILABLE{_RESET}", f"{_DIM}UNKNOWN{_RESET}")) return util = util or {} reset = reset or {} for window_label, key in (("5-hour", "five_hour"), ("7-day", "seven_day")): pct = util.get(key) usage_cell = _colour_pct(pct) if pct is not None else f"{_DIM}N/A{_RESET}" rel = _reset_in_seconds(reset.get(key), now_epoch) rows.append(( name, window_label, usage_cell, _format_relative(rel) if rel is not None else f"{_DIM}UNKNOWN{_RESET}", )) add_rows("Anthropic", anth[0], anth[1], anth[2]) add_rows("MiniMax", mini[0], mini[1], mini[2]) print_table(rows) if __name__ == "__main__": main()