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:
@@ -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(" -j, --output json Emit structured JSON (plain text, no ANSI)")
|
||||||
print(" -h, --help Show this help message and exit")
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -11,31 +11,56 @@ 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
|
||||||
|
-j, --output json Emit structured JSON to stdout (plain text, no ANSI)
|
||||||
-h, --help Show this help message and exit
|
-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.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-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.0–1.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
|
||||||
- 60–80%: yellow
|
- 60–80%: 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.0–100.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 -j # short flag for JSON
|
||||||
|
agent-subscriptions --dryrun # preview only (table)
|
||||||
|
agent-subscriptions --dryrun -j # preview only (JSON sample)
|
||||||
agent-subscriptions --help # usage
|
agent-subscriptions --help # usage
|
||||||
```
|
```
|
||||||
@@ -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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user