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
This commit is contained in:
Paul O'Reilly
2026-08-02 21:18:02 +12:00
parent 448091ecf4
commit 52eb484ef7
3 changed files with 623 additions and 93 deletions

View File

@@ -7,6 +7,7 @@ import subprocess
import sys import sys
import urllib.error import urllib.error
import urllib.request import urllib.request
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
ANTHROPIC_TOKEN_PATH = Path("~/dev/claude/secrets/anthropic/api_key").expanduser() ANTHROPIC_TOKEN_PATH = Path("~/dev/claude/secrets/anthropic/api_key").expanduser()
@@ -16,6 +17,28 @@ MINIMAX_SOPS_PATH = Path(
MINIMAX_SOPS_KEY = Path("~/dev/claude/secrets/sops/provider-age-key.txt").expanduser() MINIMAX_SOPS_KEY = Path("~/dev/claude/secrets/sops/provider-age-key.txt").expanduser()
MINIMAX_DOTENV_KEY = "ANTHROPIC_AUTH_TOKEN" 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 # ANSI colour support
_use_colour = sys.stdout.isatty() _use_colour = sys.stdout.isatty()
_GREEN = "\033[32m" if _use_colour else "" _GREEN = "\033[32m" if _use_colour else ""
@@ -67,28 +90,46 @@ def _read_minimax_key() -> str:
return _parse_dotenv_key(result.stdout, MINIMAX_DOTENV_KEY) return _parse_dotenv_key(result.stdout, MINIMAX_DOTENV_KEY)
def _parse_anthropic_headers(headers) -> dict[str, float]: def _parse_anthropic_headers(headers) -> tuple[dict[str, float], dict[str, int]]:
result = {} """Returns (utilization_pct_by_window, reset_epoch_seconds_by_window).
fh = headers.get("anthropic-ratelimit-unified-5h-utilization")
sd = headers.get("anthropic-ratelimit-unified-7d-utilization") Reset values are parsed as integer Unix epoch seconds; absent or malformed
for key, raw in [("five_hour", fh), ("seven_day", sd)]: 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: if raw is not None:
try: try:
result[key] = float(raw) * 100.0 util[key] = float(raw) * 100.0
except ValueError: except ValueError:
pass pass
return result 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) -> dict[str, float] | None: 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"} auth = {"Authorization": f"Bearer {token}", "anthropic-version": "2023-06-01"}
try: try:
req = urllib.request.Request("https://api.anthropic.com/v1/models", headers=auth) req = urllib.request.Request("https://api.anthropic.com/v1/models", headers=auth)
with urllib.request.urlopen(req, timeout=10) as resp: with urllib.request.urlopen(req, timeout=10) as resp:
result = _parse_anthropic_headers(resp.headers) util, reset = _parse_anthropic_headers(resp.headers)
if result: if util or reset:
return result return util, reset
except Exception: except Exception:
pass pass
@@ -105,21 +146,29 @@ def probe_anthropic(token: str) -> dict[str, float] | None:
method="POST", method="POST",
) )
with urllib.request.urlopen(req, timeout=15) as resp: with urllib.request.urlopen(req, timeout=15) as resp:
return _parse_anthropic_headers(resp.headers) or None util, reset = _parse_anthropic_headers(resp.headers)
if util or reset:
return util, reset
return None
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
result = _parse_anthropic_headers(e.headers) util, reset = _parse_anthropic_headers(e.headers)
return result or None if util or reset:
return util, reset
return None
except Exception: except Exception:
pass pass
return None return None
def probe_minimax(api_key: str) -> tuple[dict[str, float] | None, str | None]: def probe_minimax(api_key: str) -> tuple[dict[str, float], dict[str, int]] | tuple[None, str]:
"""Returns (usage_dict, error_reason). On success error_reason is None. """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. API v2 shape (current): model_remains[].model_name with remaining_percent fields
API v1 shape (legacy): category_remains[].category == "text_generation" with count 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. Tries v2 first; falls back to v1.
""" """
try: try:
@@ -140,28 +189,40 @@ def probe_minimax(api_key: str) -> tuple[dict[str, float] | None, str | None]:
# v2: model_remains with remaining_percent fields (general = text) # v2: model_remains with remaining_percent fields (general = text)
for entry in data.get("model_remains", []): for entry in data.get("model_remains", []):
if entry.get("model_name") == "general": if entry.get("model_name") == "general":
result = {} util: dict[str, float] = {}
reset: dict[str, int] = {}
ih = entry.get("current_interval_remaining_percent") ih = entry.get("current_interval_remaining_percent")
wh = entry.get("current_weekly_remaining_percent") wh = entry.get("current_weekly_remaining_percent")
if ih is not None: if ih is not None:
result["five_hour"] = 100.0 - float(ih) util["five_hour"] = 100.0 - float(ih)
if wh is not None: if wh is not None:
result["seven_day"] = 100.0 - float(wh) util["seven_day"] = 100.0 - float(wh)
return (result or None), None # 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 # v1 fallback: category_remains with count fields — no reset timestamps.
for cat in data.get("category_remains", []): for cat in data.get("category_remains", []):
if cat.get("category") == "text_generation": if cat.get("category") == "text_generation":
result = {} util = {}
interval_total = cat.get("current_interval_total_count", 0) interval_total = cat.get("current_interval_total_count", 0)
interval_used = cat.get("current_interval_usage_count", 0) interval_used = cat.get("current_interval_usage_count", 0)
weekly_total = cat.get("current_weekly_total_count", 0) weekly_total = cat.get("current_weekly_total_count", 0)
weekly_used = cat.get("current_weekly_usage_count", 0) weekly_used = cat.get("current_weekly_usage_count", 0)
if interval_total > 0: if interval_total > 0:
result["five_hour"] = float(interval_used) / float(interval_total) * 100.0 util["five_hour"] = float(interval_used) / float(interval_total) * 100.0
if weekly_total > 0: if weekly_total > 0:
result["seven_day"] = float(weekly_used) / float(weekly_total) * 100.0 util["seven_day"] = float(weekly_used) / float(weekly_total) * 100.0
return (result or None), None 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()) + ")" return None, "no usable plan data in response (keys: " + ", ".join(data.keys()) + ")"
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
@@ -172,7 +233,118 @@ def probe_minimax(api_key: str) -> tuple[dict[str, float] | None, str | None]:
return None, str(e) return None, str(e)
def print_dryrun() -> None: 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}[dryrun] Would probe:{_RESET}")
print(f" {_BOLD}Anthropic{_RESET} — {ANTHROPIC_TOKEN_PATH} (Bearer OAuth token)") print(f" {_BOLD}Anthropic{_RESET} — {ANTHROPIC_TOKEN_PATH} (Bearer OAuth token)")
print(f" GET https://api.anthropic.com/v1/models") print(f" GET https://api.anthropic.com/v1/models")
@@ -180,80 +352,172 @@ def print_dryrun() -> None:
print(f" GET https://www.minimax.io/v1/token_plan/remains") print(f" GET https://www.minimax.io/v1/token_plan/remains")
def print_table(rows: list[tuple[str, str, str]]) -> None: 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(f"\n{_BOLD}Agent Subscription Usage{_RESET}")
print("========================\n") print("========================\n")
col1, col2, col3 = 12, 10, 8 c1, c2, c3, c4 = col_widths
header = f"{'Provider':<{col1}} {'Window':<{col2}} {'Usage':<{col3}}" header = f"{'Provider':<{c1}} {'Window':<{c2}} {'Usage':<{c3}} {'Resets':<{c4}}"
sep = f"{'-'*col1} {'-'*col2} {'-'*col3}" sep = f"{'-'*c1} {'-'*c2} {'-'*c3} {'-'*c4}"
print(f"{_BOLD}{header}{_RESET}") print(f"{_BOLD}{header}{_RESET}")
print(sep) print(sep)
for provider, window, usage in rows: for provider, window, usage, resets in rows:
print(f"{provider:<{col1}} {window:<{col2}} {usage}") print(f"{provider:<{c1}} {window:<{c2}} {usage:<{c3}} {resets:<{c4}}")
print() 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: def main() -> None:
dryrun = False dryrun = False
json_mode = False
args = sys.argv[1:] args = sys.argv[1:]
i = 0
for arg in args: while i < len(args):
arg = args[i]
if arg in ("-h", "--help"): if arg in ("-h", "--help"):
print(__doc__) print(__doc__)
print("Usage: agent-subscriptions [OPTIONS]") print("Usage: agent-subscriptions [OPTIONS]")
print() print()
print("Options:") print("Options:")
print(" -n, --dryrun Preview probes without making API calls") print(" -n, --dryrun Preview probes without making API calls")
print(" -h, --help Show this help message and exit") print(" -j, --output json Emit structured JSON (plain text, no ANSI)")
print(" -h, --help Show this help message and exit")
sys.exit(0) sys.exit(0)
elif arg in ("-n", "--dryrun"): elif arg in ("-n", "--dryrun"):
dryrun = True 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: else:
print(f"Error: unknown option: {arg}", file=sys.stderr) print(f"Error: unknown option: {arg}", file=sys.stderr)
sys.exit(1) sys.exit(1)
if dryrun: if dryrun:
print_dryrun() print_dryrun(json_mode=json_mode)
return return
rows: list[tuple[str, str, str]] = [] 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 # Anthropic
try: try:
token = _read_anthropic_token() token = _read_anthropic_token()
usage = probe_anthropic(token) result = probe_anthropic(token)
if usage is None: if result is None:
rows.append(("Anthropic", "5-hour", f"{_DIM}UNAVAILABLE{_RESET}")) anth = (None, None, "probe returned no data")
rows.append(("Anthropic", "7-day", f"{_DIM}UNAVAILABLE{_RESET}"))
else: else:
fh = usage.get("five_hour") anth = (result[0], result[1], None)
sd = usage.get("seven_day")
rows.append(("Anthropic", "5-hour", _colour_pct(fh) if fh is not None else f"{_DIM}N/A{_RESET}"))
rows.append(("Anthropic", "7-day", _colour_pct(sd) if sd is not None else f"{_DIM}N/A{_RESET}"))
except Exception as e: except Exception as e:
rows.append(("Anthropic", "5-hour", f"{_DIM}UNAVAILABLE{_RESET}")) anth = (None, None, str(e))
rows.append(("Anthropic", "7-day", f"{_DIM}UNAVAILABLE{_RESET}"))
print(f"{_DIM}Warning: Anthropic probe failed: {e}{_RESET}", file=sys.stderr) print(f"{_DIM}Warning: Anthropic probe failed: {e}{_RESET}", file=sys.stderr)
# MiniMax # MiniMax
try: try:
key = _read_minimax_key() key = _read_minimax_key()
usage, reason = probe_minimax(key) result = probe_minimax(key)
if usage is None: if isinstance(result[1], str) and result[0] is None and not result[1]:
rows.append(("MiniMax", "5-hour", f"{_DIM}UNAVAILABLE{_RESET}")) mini = (None, None, "probe returned no data")
rows.append(("MiniMax", "7-day", f"{_DIM}UNAVAILABLE{_RESET}")) elif result[1] is not None and not isinstance(result[1], dict):
if reason: # error branch: (None, error_str)
print(f"{_DIM}Warning: MiniMax probe: {reason}{_RESET}", file=sys.stderr) mini = (None, None, result[1])
print(f"{_DIM}Warning: MiniMax probe: {result[1]}{_RESET}", file=sys.stderr)
else: else:
fh = usage.get("five_hour") util, reset = result
sd = usage.get("seven_day") mini = (util, reset, None)
rows.append(("MiniMax", "5-hour", _colour_pct(fh) if fh is not None else f"{_DIM}N/A{_RESET}"))
rows.append(("MiniMax", "7-day", _colour_pct(sd) if sd is not None else f"{_DIM}N/A{_RESET}"))
except Exception as e: except Exception as e:
rows.append(("MiniMax", "5-hour", f"{_DIM}UNAVAILABLE{_RESET}")) mini = (None, None, str(e))
rows.append(("MiniMax", "7-day", f"{_DIM}UNAVAILABLE{_RESET}"))
print(f"{_DIM}Warning: MiniMax probe failed: {e}{_RESET}", file=sys.stderr) 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) print_table(rows)

View File

@@ -2,7 +2,7 @@
## Purpose ## Purpose
Show live subscription usage percentages for each AI provider used by the agent runtimes system (Anthropic OAuth, MiniMax). Show live subscription usage percentages and weekly-reset timing for each AI provider used by the agent runtimes system (Anthropic OAuth, MiniMax). Output is human-readable by default; a `--output json` mode produces a machine-consumable structure for downstream cron jobs.
## Usage ## Usage
@@ -10,32 +10,57 @@ Show live subscription usage percentages for each AI provider used by the agent
agent-subscriptions [OPTIONS] agent-subscriptions [OPTIONS]
Options: Options:
-n, --dryrun Show what would be probed without making API calls -n, --dryrun Show what would be probed without making API calls
-h, --help Show this help message and exit -j, --output json Emit structured JSON to stdout (plain text, no ANSI)
-h, --help Show this help message and exit
``` ```
The default mode (no flag) prints a coloured table suitable for terminals.
## Behaviour ## Behaviour
1. Read the Anthropic OAuth token from `~/dev/claude/secrets/anthropic/api_key` (whole-file Bearer token). 1. Read the Anthropic OAuth token from `~/dev/claude/secrets/anthropic/api_key` (whole-file Bearer token).
2. Probe Anthropic subscription usage: 2. Probe Anthropic subscription usage and reset times:
- First attempt: `GET https://api.anthropic.com/v1/models` with `Authorization: Bearer <token>` — zero token cost; reads `anthropic-ratelimit-unified-5h-utilization` and `anthropic-ratelimit-unified-7d-utilization` response headers. - 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.01.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-4-5-20251001`, `max_tokens=1`, message `"hi"` — same headers on response (or on the HTTPError if 429). - If headers absent, fall back: `POST https://api.anthropic.com/v1/messages` with model `claude-haiku-4-5-20251001`, `max_tokens=1`, message `"hi"` — same headers on response (or on the HTTPError if 429).
- Values are fractions (0.01.0); multiply by 100 for percentage.
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`. 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: 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). - `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).
- Extract `category_remains[]` where `category == "text_generation"`. - For the `model_remains[].model_name == "general"` entry:
- `five_hour = current_interval_usage_count / current_interval_total_count × 100` - `five_hour = 100 - current_interval_remaining_percent`
- `seven_day = current_weekly_usage_count / current_weekly_total_count × 100` - `seven_day = 100 - current_weekly_remaining_percent`
5. Display a formatted table. Each provider shows two rows (5-hour and 7-day windows). If a provider probe fails, display `UNAVAILABLE` for that provider's rows. - 5-hour reset: `end_time` (Unix epoch **milliseconds**) — divide by 1000 for seconds.
6. Colour-code the percentage column: - Weekly reset: `weekly_end_time` (Unix epoch **milliseconds**).
- Source for response shape: https://github.com/Hukilow/Minimax-usage/blob/main/PLAN.md
- v1 fallback (`category_remains[]` where `category == "text_generation"`) carries no reset timestamp fields — show `UNKNOWN` reset and continue.
5. Display the formatted table (default) or JSON (`--output json`).
6. Colour-code the percentage column (default output only):
- < 60%: green - < 60%: green
- 6080%: yellow - 6080%: yellow
- ≥ 80%: red - ≥ 80%: red
- JSON output is **never** coloured (safe to pipe).
## Reset time fields
| Provider | Field | Format |
|-----------|---------------------------------------------|---------------------------|
| Anthropic | `anthropic-ratelimit-unified-5h-reset` | Unix epoch seconds (int) |
| Anthropic | `anthropic-ratelimit-unified-7d-reset` | Unix epoch seconds (int) |
| 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.
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.
## Dryrun behaviour ## Dryrun behaviour
Prints what it would probe without reading credential files or making HTTP calls: Prints what it would probe without reading credential files or making HTTP calls. The dryrun mode honours `--output json` and emits a JSON sample with placeholder values that match the live JSON schema (same keys, sample numbers, no API calls performed).
Default (no `--output`):
``` ```
[dryrun] Would probe: [dryrun] Would probe:
@@ -45,34 +70,159 @@ Prints what it would probe without reading credential files or making HTTP calls
GET https://www.minimax.io/v1/token_plan/remains GET https://www.minimax.io/v1/token_plan/remains
``` ```
With `--output json` (or `--dryrun --output json`):
```json
{
"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": 16620,
"window_seconds": 18000,
"elapsed_pct": 0.0767
},
"seven_day": {
"utilization_pct": 41.2,
"reset_at": "2026-08-07T23:00:00+00:00",
"reset_in_seconds": 538200,
"window_seconds": 604800,
"elapsed_pct": 0.1101
}
}
},
{
"provider": "MiniMax",
"available": true,
"windows": {
"five_hour": {
"utilization_pct": 12.1,
"reset_at": "2026-08-01T19:00:00+00:00",
"reset_in_seconds": 16620,
"window_seconds": 18000,
"elapsed_pct": 0.0767
},
"seven_day": {
"utilization_pct": 8.3,
"reset_at": "2026-08-07T23:00:00+00:00",
"reset_in_seconds": 538200,
"window_seconds": 604800,
"elapsed_pct": 0.1101
}
}
}
]
}
```
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).
## Output format ## Output format
### Default table
``` ```
Agent Subscription Usage Agent Subscription Usage
======================== ========================
Provider Window Usage Provider Window Usage Resets
----------- ---------- -------- ----------- ---------- -------- -------
Anthropic 5-hour 23.4% Anthropic 5-hour 23.4% 4h 37m
Anthropic 7-day 41.2% Anthropic 7-day 41.2% 6d 8h
MiniMax 5-hour 12.1% MiniMax 5-hour 12.1% 2h 11m
MiniMax 7-day 8.3% MiniMax 7-day 8.3% 4d 19h
``` ```
Percentage column is ANSI-coloured (green/yellow/red) when output is a TTY. No colour when piped. - `Resets` column shows **relative time until the window resets** (`1d 2h`, `12h 23m`, `45m`, `12s`).
- 7-day window: days+hours (`6d 8h`). 5-hour window: hours+minutes (`4h 37m`).
- `Resets` shows `UNKNOWN` (dim) when the underlying field is absent.
- Percentage column is ANSI-coloured (green/yellow/red) when stdout is a TTY; `Resets` is always plain.
- JSON output is always plain (no ANSI), regardless of TTY state.
### JSON (`--output json` / `-j`)
Top-level keys:
- `probed_at` (string, ISO 8601 with timezone offset, UTC if unknown): when the probe was performed.
- `providers` (array): one entry per provider in fixed order — `Anthropic` first, then `MiniMax`.
Each provider entry:
- `provider` (string): `"Anthropic"` or `"MiniMax"`.
- `available` (bool): `true` if at least one window returned data; `false` if the whole probe failed.
- On failure, add `error` (string) describing the reason and omit `windows`.
- On success, add `windows` (object) with both `five_hour` and `seven_day` sub-objects.
Each window sub-object:
- `utilization_pct` (float, may be `null`): percentage of the window already used (0.0100.0).
- `reset_at` (string ISO 8601, may be `null`): absolute wall-clock time of the next reset.
- `reset_in_seconds` (int, may be `null`): seconds from `probed_at` until `reset_at`. Downstream consumers computing "days remaining" divide this by 86400. **Clamped to a minimum of 0** — if the reset epoch is already in the past at probe time (clock skew, or probing in the same second the reset fired), the script clamps the value to `0` itself rather than emitting a negative number. This also pins `elapsed_pct` (below) to `1.0` in that case.
- `window_seconds` (int, may be `null`): the nominal length of this window in seconds — a fixed constant, not measured: `18000` for `five_hour`, `604800` for `seven_day`. These are assumed nominal lengths (Anthropic documents 5h/7d unified rate-limit windows; MiniMax v2's `end_time`/`weekly_end_time` fields are consistent with the same cadence) — the script does not attempt runtime verification against either API. `null` only when the window key itself has no defined constant (should not occur for `five_hour`/`seven_day`).
- `elapsed_pct` (float, may be `null`): fraction of the window already elapsed, computed as `1 (reset_in_seconds / window_seconds)`, clamped to `[0.0, 1.0]`. **`null` whenever `reset_in_seconds` is `null`** — i.e. whenever this window has no reset timestamp (the MiniMax v1 fallback path returns utilization with no reset fields). This is a deliberate fail-closed contract: downstream consumers (e.g. an idle-capacity scheduler pacing dispatch against elapsed window time) MUST treat `null` as "cannot pace this window" and never substitute `0.0` or `1.0`.
Example live output:
```json
{
"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": 16620,
"window_seconds": 18000,
"elapsed_pct": 0.0767
},
"seven_day": {
"utilization_pct": 41.2,
"reset_at": "2026-08-07T23:00:00+00:00",
"reset_in_seconds": 538200,
"window_seconds": 604800,
"elapsed_pct": 0.1101
}
}
},
{
"provider": "MiniMax",
"available": false,
"error": "HTTP 401 Unauthorized"
}
]
}
```
The downstream consumer (cron job that drafts workload based on subscription usage thresholds) needs `utilization_pct`, `reset_in_seconds`, and `elapsed_pct` for the `seven_day` window. `utilization_pct` and `reset_in_seconds` are guaranteed present (non-null) on the success path; `elapsed_pct` is guaranteed present only when the provider's reset timestamp is available (true for both providers' `v2`/header paths; `null` for MiniMax's `v1` fallback — see Edge cases).
## Edge cases ## Edge cases
- If the Anthropic token file does not exist, print `UNAVAILABLE` for both Anthropic rows and continue. - If the Anthropic token file does not exist, mark Anthropic `available: false`, set `error`, continue.
- If SOPS decryption fails (missing key file, wrong key, sops not installed), print `UNAVAILABLE` for both MiniMax rows and continue. - If SOPS decryption fails (missing key file, wrong key, sops not installed), mark MiniMax `available: false`, set `error`, continue.
- If an API call fails for any reason, print `UNAVAILABLE` for that provider's rows and continue. - If an API call fails for any reason, that provider is `available: false` with `error`. The other provider is still probed.
- If a utilization header is present for only one window, display `N/A` for the missing window. - If a utilization header is present for only one window, the other window has `utilization_pct: null`. The reset field is independent — a window can have a reset timestamp without a utilization value, or vice versa.
- Exit 0 even if some providers are unavailable (the tool is informational). - If the reset field is absent for a window: `reset_at: null`, `reset_in_seconds: null`, `elapsed_pct: null`. The default table prints `UNKNOWN` in the `Resets` cell. This is the normal case for MiniMax's v1 fallback path (`category_remains[]`), which carries utilization but no reset timestamps.
- If `reset_in_seconds` would be negative (reset epoch already passed at probe time), it is clamped to `0` before being emitted — never negative in JSON output. `elapsed_pct` for that window is then `1.0` (fully elapsed), not `null`.
- `elapsed_pct` is computed only from `reset_in_seconds` and the fixed `window_seconds` constant; it does not depend on `utilization_pct`. A window can have `elapsed_pct` with `utilization_pct: null`, or vice versa.
- `window_seconds` values (`18000`, `604800`) are the same regardless of provider or probe outcome — they are compile-time constants, not derived from any API response.
- Exit 0 even if some providers are unavailable (the tool is informational). The only non-zero exit is `--help` is not requested and an unknown flag was passed.
- `--output json` and `--dryrun` compose: `--dryrun --output json` emits the dryrun JSON sample without making API calls.
## Examples ## Examples
```sh ```sh
agent-subscriptions # live probe agent-subscriptions # live probe, coloured table
agent-subscriptions --dryrun # preview only agent-subscriptions --output json # live probe, machine-readable JSON
agent-subscriptions --help # usage agent-subscriptions -j # short flag for JSON
agent-subscriptions --dryrun # preview only (table)
agent-subscriptions --dryrun -j # preview only (JSON sample)
agent-subscriptions --help # usage
``` ```

View File

@@ -48,6 +48,122 @@ echo "$help_out" | grep -q "\-\-dryrun\|-n" && pass "--help documents dryrun" ||
code=$? code=$?
[[ $code -ne 0 ]] && pass "unknown flag exits non-zero" || fail "unknown flag exits non-zero (got 0)" [[ $code -ne 0 ]] && pass "unknown flag exits non-zero" || fail "unknown flag exits non-zero (got 0)"
# --help: documents --output
help_out=$("$SCRIPT" --help 2>&1)
echo "$help_out" | grep -q "\-\-output" && pass "--help documents --output" || fail "--help documents --output"
# --help: documents -j short flag
echo "$help_out" | grep -q "\-j" && pass "--help documents -j" || fail "--help documents -j"
# --output json: exits 0
json_out=$("$SCRIPT" --dryrun --output json 2>&1)
code=$?
[[ $code -eq 0 ]] && pass "--output json exits 0" || fail "--output json exits 0 (got $code)"
# --output json: contains top-level "providers" key
echo "$json_out" | grep -q '"providers"' && pass "--output json contains providers key" || fail "--output json contains providers key"
# --output json: contains probed_at key
echo "$json_out" | grep -q '"probed_at"' && pass "--output json contains probed_at" || fail "--output json contains probed_at"
# --output json: contains Anthropic provider name
echo "$json_out" | grep -q '"Anthropic"' && pass "--output json contains Anthropic" || fail "--output json contains Anthropic"
# --output json: contains MiniMax provider name
echo "$json_out" | grep -q '"MiniMax"' && pass "--output json contains MiniMax" || fail "--output json contains MiniMax"
# --output json: contains utilization_pct field
echo "$json_out" | grep -q '"utilization_pct"' && pass "--output json contains utilization_pct" || fail "--output json contains utilization_pct"
# --output json: contains reset_at field
echo "$json_out" | grep -q '"reset_at"' && pass "--output json contains reset_at" || fail "--output json contains reset_at"
# --output json: contains reset_in_seconds field
echo "$json_out" | grep -q '"reset_in_seconds"' && pass "--output json contains reset_in_seconds" || fail "--output json contains reset_in_seconds"
# --output json: contains seven_day window
echo "$json_out" | grep -q '"seven_day"' && pass "--output json contains seven_day window" || fail "--output json contains seven_day window"
# --output json: contains five_hour window
echo "$json_out" | grep -q '"five_hour"' && pass "--output json contains five_hour window" || fail "--output json contains five_hour window"
# --output json: well-formed JSON (python json.loads parses it)
echo "$json_out" | python3 -c "import sys, json; json.loads(sys.stdin.read())" 2>/dev/null
[[ $? -eq 0 ]] && pass "--output json is valid JSON" || fail "--output json is valid JSON"
# -j (short flag): identical to --output json
short_out=$("$SCRIPT" --dryrun -j 2>&1)
[[ "$json_out" == "$short_out" ]] && pass "-j matches --output json output" || fail "-j matches --output json output"
# -j (short flag): exits 0
"$SCRIPT" --dryrun -j >/dev/null 2>&1
code=$?
[[ $code -eq 0 ]] && pass "-j exits 0" || fail "-j exits 0 (got $code)"
# --output=json (equals form): exits 0
"$SCRIPT" --dryrun --output=json >/dev/null 2>&1
code=$?
[[ $code -eq 0 ]] && pass "--output=json exits 0" || fail "--output=json exits 0 (got $code)"
# --output json (default mode unchanged): dryrun emits probe preview, not JSON
table_out=$("$SCRIPT" --dryrun 2>&1)
echo "$table_out" | grep -q "\[dryrun\]" && pass "default dryrun emits [dryrun] header" || fail "default dryrun emits [dryrun] header"
echo "$table_out" | grep -q '"providers"' && fail "default mode should not emit JSON" || pass "default mode does not emit JSON"
# --dryrun --output json: no API calls attempted (output is JSON, not probe failure text)
echo "$json_out" | grep -qi "failed\|UNAVAILABLE\|HTTP " && fail "--dryrun --output json made API calls" || pass "--dryrun --output json made no API calls"
# --output non-json value: exits non-zero
"$SCRIPT" --dryrun --output yaml >/dev/null 2>&1
code=$?
[[ $code -ne 0 ]] && pass "--output yaml exits non-zero" || fail "--output yaml exits non-zero (got 0)"
# --output with no value: exits non-zero
"$SCRIPT" --output >/dev/null 2>&1
code=$?
[[ $code -ne 0 ]] && pass "--output with no value exits non-zero" || fail "--output with no value exits non-zero (got 0)"
# --output json: contains window_seconds field
echo "$json_out" | grep -q '"window_seconds"' && pass "--output json contains window_seconds" || fail "--output json contains window_seconds"
# --output json: contains elapsed_pct field
echo "$json_out" | grep -q '"elapsed_pct"' && pass "--output json contains elapsed_pct" || fail "--output json contains elapsed_pct"
# --output json: window_seconds and elapsed_pct present on every window, per provider,
# window_seconds equals the expected constant, elapsed_pct in [0,1] or null.
python3 - "$json_out" <<'PYEOF'
import json, sys
data = json.loads(sys.argv[1])
expected_window_seconds = {"five_hour": 18000, "seven_day": 604800}
ok = True
for provider in data.get("providers", []):
windows = provider.get("windows")
if windows is None:
continue # unavailable provider, no windows to check
for key, expected_seconds in expected_window_seconds.items():
win = windows.get(key)
if win is None:
print(f"FAIL: {provider.get('provider')} missing window {key}")
ok = False
continue
if "window_seconds" not in win or "elapsed_pct" not in win:
print(f"FAIL: {provider.get('provider')}.{key} missing window_seconds/elapsed_pct")
ok = False
continue
if win["window_seconds"] != expected_seconds:
print(f"FAIL: {provider.get('provider')}.{key} window_seconds={win['window_seconds']!r}, expected {expected_seconds}")
ok = False
pct = win["elapsed_pct"]
if pct is not None and not (0.0 <= pct <= 1.0):
print(f"FAIL: {provider.get('provider')}.{key} elapsed_pct={pct!r} out of [0,1]")
ok = False
sys.exit(0 if ok else 1)
PYEOF
[[ $? -eq 0 ]] && pass "--output json: window_seconds/elapsed_pct present, valid, and match constants" || fail "--output json: window_seconds/elapsed_pct present, valid, and match constants"
echo echo
if [[ $FAILURES -eq 0 ]]; then if [[ $FAILURES -eq 0 ]]; then
echo -e "${GREEN}All tests passed.${RESET}" echo -e "${GREEN}All tests passed.${RESET}"