Files
small-scripts/scripts/agent-subscriptions
Paul O'Reilly 52eb484ef7 agent-subscriptions: add window_seconds and elapsed_pct to JSON output
Elapsed is null when a window has no reset timestamp (MiniMax v1 fallback)
so pacing consumers fail closed; negative reset_in_seconds clamps to 0.

Claude-Session: https://claude.ai/code/session_01YQDoWNM7XPPii28khFWoMc
2026-08-02 21:18:02 +12:00

526 lines
20 KiB
Python
Executable File

#!/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()