claude-multiacc 2.0.37 → 2.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/bin/claude-accounts +7 -7
- package/bin/codex-accounts +6 -3
- package/docs/CODEX.md +3 -2
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/claude_reset.py +70 -35
- package/lib/codex_reset.py +31 -4
- package/lib/codex_reset_telemetry.py +4 -2
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/test_claude_reset.py +61 -11
- package/tests/test_codex_reset.py +43 -2
package/README.md
CHANGED
|
@@ -177,17 +177,17 @@ buckets keep being tracked and the ≥90% rule keeps working. Unparseable entrie
|
|
|
177
177
|
skipped, an all-legacy payload falls back to `five_hour`/`seven_day`, and a payload the
|
|
178
178
|
code cannot read at all degrades that one account (fail open), never the run.
|
|
179
179
|
|
|
180
|
-
**
|
|
180
|
+
**A parked account gets its limit reset automatically.** Claude
|
|
181
181
|
Code 2.1.280 added `/limit-reset`: an account can hold a *grant* (the Opus 5.5 launch gave
|
|
182
182
|
every Pro/Max account one reset, usable until 2026-10-22) that refills its five-hour and
|
|
183
183
|
weekly limits at once while the weekly reset day stays put. The same usage call carries the
|
|
184
184
|
grant's status (`?cedar_ember=1&skip_spend=1`, Claude Code's own read, so it costs nothing
|
|
185
185
|
against the endpoint's hourly budget), and the limits pass claims it with
|
|
186
|
-
`POST /api/organizations/<org>/reset_rate_limits`
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
186
|
+
`POST /api/organizations/<org>/reset_rate_limits` whenever the pool has **parked** the account on a
|
|
187
|
+
window the grant refills — a bucket at/over the pool's own threshold (90%), a limit the server
|
|
188
|
+
reports exhausted, or an active park (a client 429, or the app-robot panel's own park) — and holds
|
|
189
|
+
it only when every such park lifts on its own within the hour (`CLAUDE_MULTIACC_RESET_MIN_HORIZON`,
|
|
190
|
+
seconds). The claim carries a request id derived
|
|
191
191
|
from the account, grant, remaining count and week, persisted before the POST, so a lost
|
|
192
192
|
response is retried with the same id and fleet peers converge on one claim; a claim whose
|
|
193
193
|
answer never came back is settled by the grant's own remaining count dropping, and a claim
|
package/bin/claude-accounts
CHANGED
|
@@ -81,10 +81,10 @@ USAGE
|
|
|
81
81
|
or runner daemon distributes.
|
|
82
82
|
claude-accounts verify [--quick] auth matrix; full mode runs `-p "reply OK"` per account
|
|
83
83
|
claude-accounts limits [--quiet] [--force] [--json]
|
|
84
|
-
refresh usage buckets, apply >=90% markers. When
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
refresh usage buckets, apply >=90% markers. When the pool has parked an
|
|
85
|
+
account on a window its Claude Code limit reset (/limit-reset) refills,
|
|
86
|
+
redeems it with an idempotent request — unless every park lifts on its own
|
|
87
|
+
within the hour.
|
|
88
88
|
Auto-refreshes long-expired
|
|
89
89
|
OAuth access tokens via the refresh-token grant (rotated credential is
|
|
90
90
|
persisted), so idle accounts keep fresh telemetry and stay selectable.
|
|
@@ -105,8 +105,8 @@ ENV
|
|
|
105
105
|
CLAUDE_SHIM_RETRY=0 disable -p auto-retry
|
|
106
106
|
CLAUDE_MULTIACC_DISABLE=1 bypass the shim entirely
|
|
107
107
|
CLAUDE_MULTIACC_AUTO_RESET=0 disable automatic limit-reset redemption
|
|
108
|
-
CLAUDE_MULTIACC_RESET_MIN_HORIZON
|
|
109
|
-
|
|
108
|
+
CLAUDE_MULTIACC_RESET_MIN_HORIZON a reset is held while every park lifts on
|
|
109
|
+
its own within this many seconds (default 3600)
|
|
110
110
|
EOF
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -2408,7 +2408,7 @@ for acct in manifest.get('accounts', []):
|
|
|
2408
2408
|
if source == 'oauth':
|
|
2409
2409
|
try:
|
|
2410
2410
|
reset_result, reset_view = claude_reset.refresh_reset_credits(
|
|
2411
|
-
d, aid, data, url, headers, say, int(now))
|
|
2411
|
+
d, aid, data, url, headers, say, int(now), threshold)
|
|
2412
2412
|
except Exception as e:
|
|
2413
2413
|
say(f'{aid}: limit reset automation failed unexpectedly ({type(e).__name__}); '
|
|
2414
2414
|
f'failing open')
|
package/bin/codex-accounts
CHANGED
|
@@ -89,8 +89,10 @@ USAGE
|
|
|
89
89
|
`codex exec` per account
|
|
90
90
|
codex-accounts limits [--quiet] [--force] [--json]
|
|
91
91
|
refresh usage windows from the ChatGPT usage endpoint, apply >=90% markers.
|
|
92
|
-
When
|
|
93
|
-
|
|
92
|
+
When the pool has parked the account (a window at/over the threshold, a
|
|
93
|
+
finished limit, or a client-reported limit that lifts more than an hour from
|
|
94
|
+
now), automatically redeems an earned usage-limit reset if the account has
|
|
95
|
+
one, using an idempotent request.
|
|
94
96
|
Auto-refreshes long-expired access tokens via the OAuth refresh-token grant
|
|
95
97
|
(rotated credential is persisted), so idle accounts keep fresh telemetry and
|
|
96
98
|
stay selectable. Skips accounts fetched in the last 45s and honors
|
|
@@ -1620,7 +1622,8 @@ for acct in manifest.get('accounts', []):
|
|
|
1620
1622
|
if sessionp is None and live:
|
|
1621
1623
|
sessionp = 0
|
|
1622
1624
|
try:
|
|
1623
|
-
reset_result, reset_view = refresh_reset_credits(d, aid, maxp, data, url, headers, say,
|
|
1625
|
+
reset_result, reset_view = refresh_reset_credits(d, aid, maxp, data, url, headers, say,
|
|
1626
|
+
int(now), threshold)
|
|
1624
1627
|
except Exception as e:
|
|
1625
1628
|
say(f'{aid}: usage reset automation failed unexpectedly ({type(e).__name__}); failing open')
|
|
1626
1629
|
reset_result = {'status': 'error'}
|
package/docs/CODEX.md
CHANGED
|
@@ -44,8 +44,9 @@ Codex-specific notes:
|
|
|
44
44
|
own public client id) and the rotated credential is persisted 0600. Overrides for
|
|
45
45
|
tests: `CODEX_MULTIACC_TOKEN_URL`, `CODEX_MULTIACC_CLIENT_ID`,
|
|
46
46
|
`CODEX_MULTIACC_USAGE_URL`.
|
|
47
|
-
- **Earned usage resets are redeemed automatically
|
|
48
|
-
|
|
47
|
+
- **Earned usage resets are redeemed automatically for a parked account:** a bucket
|
|
48
|
+
at/over the pool's exclusion threshold (90%), a limit the backend reports finished, or
|
|
49
|
+
an active usage park (a client-reported 429) that lifts more than an hour from now. The limits refresher checks the reset
|
|
49
50
|
credits belonging to that same account, uses the soonest-expiring available credit,
|
|
50
51
|
and writes a per-account/window idempotency key before redemption so a lost response
|
|
51
52
|
or simultaneous fleet poll cannot spend a second reset. Set
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/claude_reset.py
CHANGED
|
@@ -6,19 +6,15 @@ names (``clears``: five_hour, seven_day, seven_day_overage_included, ...) while
|
|
|
6
6
|
weekly reset day stays where it was. The first grant arrived with the Opus 5.5
|
|
7
7
|
launch on 2026-09-22 — one reset per Pro/Max account, usable until 2026-10-22.
|
|
8
8
|
|
|
9
|
-
This is the Claude half of lib/codex_reset.py
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
the week's refill on it throws nearly all of the reset away.
|
|
19
|
-
* It is held while the account's own weekly reset is less than
|
|
20
|
-
``CLAUDE_MULTIACC_RESET_MIN_HORIZON`` seconds away (default 24h): the week is about
|
|
21
|
-
to refill for free, and the grant is worth days of work later in its validity.
|
|
9
|
+
This is the Claude half of lib/codex_reset.py, and it follows the operator's rule:
|
|
10
|
+
an account the pool has PARKED on a window the grant refills gets its reset, so it goes
|
|
11
|
+
back to work instead of sitting out the wall. Parked means exactly what the shim means
|
|
12
|
+
by it — a bucket the grant clears at or above the pool's own exclusion threshold (90%
|
|
13
|
+
by default, the manifest's `threshold`), a limit the server reports exhausted, or an
|
|
14
|
+
active `.limited` park (a 429 the client received, or the app-robot panel's park) on
|
|
15
|
+
such a window. The one hold: when every such park lifts on its own within
|
|
16
|
+
``CLAUDE_MULTIACC_RESET_MIN_HORIZON`` seconds (default 1h), the reset is kept — a
|
|
17
|
+
reset is one irreversible shot (support.claude.com "What is a limit reset?").
|
|
22
18
|
|
|
23
19
|
What differs from codex beyond that is only the transport:
|
|
24
20
|
|
|
@@ -61,7 +57,7 @@ RESET_AT_USED_PERCENT = 95
|
|
|
61
57
|
REDEEM_COOLDOWN_SECONDS = 900
|
|
62
58
|
# Limit types that refill on their own within hours; never a reason to spend a reset.
|
|
63
59
|
SESSION_LIMITS = frozenset({"five_hour"})
|
|
64
|
-
DEFAULT_MIN_HORIZON_SECONDS =
|
|
60
|
+
DEFAULT_MIN_HORIZON_SECONDS = 3600
|
|
65
61
|
# A claim whose response was lost is retried with the same request id while it is
|
|
66
62
|
# younger than this, and dropped afterwards unless the status block proved it landed.
|
|
67
63
|
PENDING_TTL_SECONDS = 3600
|
|
@@ -296,13 +292,51 @@ def _percent(grant: dict, usage: dict, limit_type: str) -> int | None:
|
|
|
296
292
|
return None
|
|
297
293
|
|
|
298
294
|
|
|
299
|
-
def
|
|
300
|
-
"""
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
295
|
+
def _lift_epoch(limit_type: str, status: dict, usage: dict) -> float | None:
|
|
296
|
+
"""When a parked window refills on its own: the session window's reset, or the week's."""
|
|
297
|
+
if limit_type in SESSION_LIMITS:
|
|
298
|
+
for limit in (usage.get("limits") or []) if isinstance(usage, dict) else []:
|
|
299
|
+
if isinstance(limit, dict) and str(limit.get("kind") or "").startswith("session"):
|
|
300
|
+
found = _epoch(limit.get("resets_at"))
|
|
301
|
+
if found is not None:
|
|
302
|
+
return found
|
|
303
|
+
bucket = usage.get("five_hour") if isinstance(usage, dict) else None
|
|
304
|
+
return _epoch(bucket.get("resets_at")) if isinstance(bucket, dict) else None
|
|
305
|
+
return _epoch(status.get("weekly_resets_at"))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _active_park(account_dir: str, now: float) -> tuple[str | None, float] | None:
|
|
309
|
+
"""(limit type, lift epoch) of the account's `.limited` park, when it is a USAGE park
|
|
310
|
+
still in force and not already lifted by a reset on record; else None."""
|
|
311
|
+
try:
|
|
312
|
+
with open(os.path.join(account_dir, ".limited"), errors="replace") as handle:
|
|
313
|
+
text = handle.read(4096)
|
|
314
|
+
until = int(text.splitlines()[0])
|
|
315
|
+
except (OSError, ValueError, IndexError):
|
|
316
|
+
return None
|
|
317
|
+
if until <= now or not ("reason=limits" in text or "reason=client-rate-limit" in text):
|
|
318
|
+
return None
|
|
319
|
+
record = latest(record_from(_load(os.path.join(account_dir, "limits.json")), now),
|
|
320
|
+
local_record(account_dir, now))
|
|
321
|
+
if marker_superseded(text, record):
|
|
322
|
+
return None
|
|
323
|
+
bucket = next((part[len("bucket="):] for part in text.split()
|
|
324
|
+
if part.startswith("bucket=")), "")
|
|
325
|
+
return bucket_limit_type(bucket), float(until)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def parked_on(status: dict, grant: dict, usage: dict, account_dir: str, threshold: int,
|
|
329
|
+
now: float) -> list[tuple[str, float | None]]:
|
|
330
|
+
"""Every (limit type, when it lifts on its own) the pool parks this account on,
|
|
331
|
+
among the windows this grant refills."""
|
|
332
|
+
clears = set(grant["clears"])
|
|
333
|
+
parks = [(t, _lift_epoch(t, status, usage)) for t in sorted(clears & set(status["exhausted"]))]
|
|
334
|
+
parks += [(t, _lift_epoch(t, status, usage)) for t in sorted(clears)
|
|
335
|
+
if (_percent(grant, usage, t) or 0) >= threshold]
|
|
336
|
+
park = _active_park(account_dir, now)
|
|
337
|
+
if park and (park[0] == "*" or park[0] in clears):
|
|
338
|
+
parks.append(park)
|
|
339
|
+
return parks
|
|
306
340
|
|
|
307
341
|
|
|
308
342
|
# ---- the reset record the whole fleet honours -------------------------------------
|
|
@@ -525,7 +559,8 @@ def _complete(state_path: str, pending: dict, outcome: str, now: int, cleared=No
|
|
|
525
559
|
|
|
526
560
|
|
|
527
561
|
def try_auto_redeem(account_dir: str, account_id: str, usage: dict, status: dict | None,
|
|
528
|
-
usage_url: str, headers: dict, say, now: int | None = None
|
|
562
|
+
usage_url: str, headers: dict, say, now: int | None = None,
|
|
563
|
+
threshold: int = RESET_AT_USED_PERCENT) -> dict:
|
|
529
564
|
"""Redeem one reset when policy says so and return a non-secret outcome document."""
|
|
530
565
|
current = int(now if now is not None else time.time())
|
|
531
566
|
if not auto_reset_enabled():
|
|
@@ -558,7 +593,7 @@ def try_auto_redeem(account_dir: str, account_id: str, usage: dict, status: dict
|
|
|
558
593
|
return {"status": "cooldown", "outcome": state.get("outcome")}
|
|
559
594
|
if not status["eligible"]:
|
|
560
595
|
reason = status["ineligible_reason"] or "unknown"
|
|
561
|
-
if reason in _REQUEST_SIDE_REASONS and _usage_peak(usage) >=
|
|
596
|
+
if reason in _REQUEST_SIDE_REASONS and _usage_peak(usage) >= threshold:
|
|
562
597
|
# Not a fact about the account: the server would not answer THIS client. On the
|
|
563
598
|
# one machine that can redeem, that silently turns redemption off fleet-wide.
|
|
564
599
|
say(f"{account_id}: limit-reset status refused for this client ({reason}; "
|
|
@@ -571,20 +606,20 @@ def try_auto_redeem(account_dir: str, account_id: str, usage: dict, status: dict
|
|
|
571
606
|
grant = _usable_grant(status, current)
|
|
572
607
|
if grant is None:
|
|
573
608
|
return {"status": "no_credit"}
|
|
574
|
-
|
|
575
|
-
if not
|
|
609
|
+
parks = parked_on(status, grant, usage, account_dir, threshold, current)
|
|
610
|
+
if not parks:
|
|
576
611
|
return {"status": "not_eligible"}
|
|
612
|
+
parked = sorted({t for t, _ in parks})
|
|
577
613
|
if grant["blocking"]:
|
|
578
614
|
# The client's own rule: a limit this grant does not refill is exhausted too,
|
|
579
615
|
# so refilling the others would not let the account work.
|
|
580
616
|
return {"status": "not_eligible", "reason": "blocked",
|
|
581
617
|
"blocking": grant["blocking"]}
|
|
582
|
-
if grant["use_requires_limit"] and not
|
|
618
|
+
if grant["use_requires_limit"] and not set(grant["clears"]) & set(status["exhausted"]):
|
|
583
619
|
return {"status": "not_eligible", "reason": "waiting_for_limit"}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
f"limit but the week refills on its own at {status['weekly_resets_at']}")
|
|
620
|
+
if all(lift is not None and lift - current < min_horizon() for _, lift in parks):
|
|
621
|
+
say(f"{account_id}: limit reset held — parked on {', '.join(parked)}, which "
|
|
622
|
+
f"lifts on its own within {min_horizon() // 60} minutes")
|
|
588
623
|
return {"status": "not_eligible", "reason": "natural_reset_soon"}
|
|
589
624
|
org = organization_uuid(account_dir)
|
|
590
625
|
url = claim_url(usage_url, org) if org else None
|
|
@@ -600,7 +635,7 @@ def try_auto_redeem(account_dir: str, account_id: str, usage: dict, status: dict
|
|
|
600
635
|
"request_id": request_id(org, account_uuid(account_dir) or account_id,
|
|
601
636
|
grant, status),
|
|
602
637
|
"resets_left_before": grant["resets_left"], "started_at": current,
|
|
603
|
-
"clears": grant["clears"], "trigger":
|
|
638
|
+
"clears": grant["clears"], "trigger": parked}
|
|
604
639
|
_write(state_path, state)
|
|
605
640
|
payload = {"program": PROGRAM, "grant_id": state["grant_id"],
|
|
606
641
|
"request_id": state["request_id"]}
|
|
@@ -631,8 +666,7 @@ def try_auto_redeem(account_dir: str, account_id: str, usage: dict, status: dict
|
|
|
631
666
|
left = _count(response.get("resets_left"))
|
|
632
667
|
if outcome in {"reset", "already_used"}:
|
|
633
668
|
said = "redeemed" if outcome == "reset" else "already redeemed"
|
|
634
|
-
say(f"{account_id}: limit reset {said} automatically "
|
|
635
|
-
f"({', '.join(finished or near)} at limit)")
|
|
669
|
+
say(f"{account_id}: limit reset {said} automatically (parked on {', '.join(parked)})")
|
|
636
670
|
return _complete(state_path, state, outcome, current, cleared, left)
|
|
637
671
|
if outcome == "unavailable":
|
|
638
672
|
say(f"{account_id}: limit reset not confirmed (server unavailable); retry is idempotent")
|
|
@@ -648,7 +682,8 @@ def try_auto_redeem(account_dir: str, account_id: str, usage: dict, status: dict
|
|
|
648
682
|
return {"status": outcome}
|
|
649
683
|
|
|
650
684
|
|
|
651
|
-
def refresh_reset_credits(account_dir, account_id, usage, usage_url, headers, say, now
|
|
685
|
+
def refresh_reset_credits(account_dir, account_id, usage, usage_url, headers, say, now,
|
|
686
|
+
threshold=RESET_AT_USED_PERCENT):
|
|
652
687
|
"""Read the allowance from this pass's usage response, redeem if due, report both.
|
|
653
688
|
|
|
654
689
|
The view reported after a claim is the server's own post-claim count (the claim
|
|
@@ -658,7 +693,7 @@ def refresh_reset_credits(account_dir, account_id, usage, usage_url, headers, sa
|
|
|
658
693
|
status = parse_status(usage)
|
|
659
694
|
view = credits_view(status, now)
|
|
660
695
|
result = try_auto_redeem(account_dir, account_id, usage, status, usage_url, headers,
|
|
661
|
-
say, now)
|
|
696
|
+
say, now, threshold)
|
|
662
697
|
if result.get("status") == "redeemed":
|
|
663
698
|
left = result.get("resets_left")
|
|
664
699
|
if view and type(left) is int:
|
package/lib/codex_reset.py
CHANGED
|
@@ -22,6 +22,23 @@ from codex_reset_windows import reset_windows
|
|
|
22
22
|
RESET_AT_USED_PERCENT = 95
|
|
23
23
|
REDEEM_COOLDOWN_SECONDS = 900
|
|
24
24
|
STATE_FILE = ".usage-reset.json"
|
|
25
|
+
# A park that lifts on its own within this long keeps the credit (operator rule,
|
|
26
|
+
# 2026-09-22: a PARKED account uses its reset; only a sub-hour wall is not worth one).
|
|
27
|
+
PARK_HOLD_SECONDS = 3600
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _active_park(account_dir: str, now: int) -> int | None:
|
|
31
|
+
"""The epoch a USAGE park on this account lifts at (a >=threshold pass, or a 429 the
|
|
32
|
+
client received), or None. Error cool-downs and expired markers are not parks."""
|
|
33
|
+
try:
|
|
34
|
+
with open(os.path.join(account_dir, ".limited"), errors="replace") as handle:
|
|
35
|
+
text = handle.read(4096)
|
|
36
|
+
until = int(text.splitlines()[0])
|
|
37
|
+
except (OSError, ValueError, IndexError):
|
|
38
|
+
return None
|
|
39
|
+
if until <= now or not ("reason=limits" in text or "reason=client-rate-limit" in text):
|
|
40
|
+
return None
|
|
41
|
+
return until
|
|
25
42
|
|
|
26
43
|
|
|
27
44
|
def auto_reset_enabled() -> bool:
|
|
@@ -111,11 +128,20 @@ def _redeem_request_id(account_id: str, usage: dict, headers: dict, now: int) ->
|
|
|
111
128
|
|
|
112
129
|
def try_auto_redeem(account_dir: str, account_id: str, used_percent: int,
|
|
113
130
|
usage: dict, usage_url: str, headers: dict, say,
|
|
114
|
-
now: int | None = None, credit_details: dict | None = None
|
|
115
|
-
|
|
131
|
+
now: int | None = None, credit_details: dict | None = None,
|
|
132
|
+
threshold: int = RESET_AT_USED_PERCENT) -> dict:
|
|
133
|
+
"""Redeem one available reset and return a non-secret outcome document.
|
|
134
|
+
|
|
135
|
+
Eligible whenever the pool has PARKED the account: a window at/over the pool's own
|
|
136
|
+
exclusion threshold (90 by default — the 95% line left accounts at 90-94% parked for
|
|
137
|
+
days with credits unused), a finished limit, or an active usage park that lifts more
|
|
138
|
+
than PARK_HOLD_SECONDS from now (a 429 the telemetry may not show)."""
|
|
116
139
|
current = int(now if now is not None else time.time())
|
|
117
140
|
finished, _ = reset_windows(usage, RESET_AT_USED_PERCENT)
|
|
118
|
-
|
|
141
|
+
park = _active_park(account_dir, current)
|
|
142
|
+
parked = park is not None and park - current >= PARK_HOLD_SECONDS
|
|
143
|
+
if not auto_reset_enabled() or (used_percent < min(threshold, RESET_AT_USED_PERCENT)
|
|
144
|
+
and not finished and not parked):
|
|
119
145
|
return {"status": "not_eligible"}
|
|
120
146
|
state, disposition = _pending_request(account_dir, current)
|
|
121
147
|
if disposition == "cooldown":
|
|
@@ -151,7 +177,8 @@ def try_auto_redeem(account_dir: str, account_id: str, used_percent: int,
|
|
|
151
177
|
"redeemed_at": current, "suppress_until": current + REDEEM_COOLDOWN_SECONDS,
|
|
152
178
|
"windows_reset": int(response.get("windows_reset") or 0)}
|
|
153
179
|
_write(os.path.join(account_dir, STATE_FILE), complete)
|
|
154
|
-
say(f"{account_id}: usage reset redeemed automatically at {used_percent}% used"
|
|
180
|
+
say(f"{account_id}: usage reset redeemed automatically at {used_percent}% used"
|
|
181
|
+
+ ("" if used_percent >= threshold or finished else " (parked by a client-reported limit)"))
|
|
155
182
|
return {"status": "redeemed", "outcome": outcome,
|
|
156
183
|
"windows_reset": complete["windows_reset"], "redeemed_at": current}
|
|
157
184
|
try:
|
|
@@ -18,7 +18,8 @@ def _read_credits(url, headers, account_id, say):
|
|
|
18
18
|
return {}, {}
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def refresh_reset_credits(account_dir, account_id, used_percent, usage, usage_url, headers, say, now
|
|
21
|
+
def refresh_reset_credits(account_dir, account_id, used_percent, usage, usage_url, headers, say, now,
|
|
22
|
+
threshold=None):
|
|
22
23
|
"""Read all accounts, share the GET with redemption, then read back after a mutation.
|
|
23
24
|
|
|
24
25
|
Failed reads omit the count; neither an embedded summary nor subtraction proves
|
|
@@ -27,8 +28,9 @@ def refresh_reset_credits(account_dir, account_id, used_percent, usage, usage_ur
|
|
|
27
28
|
"""
|
|
28
29
|
list_url, _ = _reset_urls(usage_url)
|
|
29
30
|
details, view = _read_credits(list_url, headers, account_id, say)
|
|
31
|
+
extra = {} if threshold is None else {"threshold": threshold}
|
|
30
32
|
result = try_auto_redeem(account_dir, account_id, used_percent, usage, usage_url,
|
|
31
|
-
headers, say, now, credit_details=details)
|
|
33
|
+
headers, say, now, credit_details=details, **extra)
|
|
32
34
|
if result.get("status") not in {"not_eligible", "cooldown", "no_credit", "error"}:
|
|
33
35
|
_, view = _read_credits(list_url, headers, account_id, say)
|
|
34
36
|
return result, view
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -36,7 +36,7 @@ def iso(offset: int) -> str:
|
|
|
36
36
|
def usage_doc(*, session=0, weekly=50, fable=50, exhausted=(), resets_left=1,
|
|
37
37
|
use_requires_limit=False, eligible=True, reason=None, blocking=(),
|
|
38
38
|
clears=("five_hour", "seven_day", "seven_day_overage_included"),
|
|
39
|
-
usable=True, week_resets_in=3 * 86400) -> dict:
|
|
39
|
+
usable=True, week_resets_in=3 * 86400, session_resets_in=3600) -> dict:
|
|
40
40
|
"""A usage response in the live 2026-09-22 shape, cedar_ember block included."""
|
|
41
41
|
grants = [{
|
|
42
42
|
"id": GRANT, "label": "Claude Opus 5.5 launch: one usage-limit reset for Pro and Max",
|
|
@@ -50,7 +50,7 @@ def usage_doc(*, session=0, weekly=50, fable=50, exhausted=(), resets_left=1,
|
|
|
50
50
|
return {
|
|
51
51
|
"limits": [
|
|
52
52
|
{"kind": "session", "group": "session", "percent": session,
|
|
53
|
-
"resets_at": iso(
|
|
53
|
+
"resets_at": iso(session_resets_in) if session else None, "scope": None},
|
|
54
54
|
{"kind": "weekly_all", "group": "weekly", "percent": weekly,
|
|
55
55
|
"resets_at": iso(week_resets_in), "scope": None},
|
|
56
56
|
{"kind": "weekly_scoped", "group": "weekly", "percent": fable,
|
|
@@ -234,22 +234,65 @@ class ClaudeResetIntegrationTest(ClaudePoolSandbox, unittest.TestCase):
|
|
|
234
234
|
self.run_limits()
|
|
235
235
|
self.assertEqual(len(self.server.state["posts"]), 1)
|
|
236
236
|
|
|
237
|
-
def
|
|
238
|
-
self.server.state["usage"] = usage_doc(session=
|
|
237
|
+
def test_does_not_redeem_below_the_pools_own_threshold(self) -> None:
|
|
238
|
+
self.server.state["usage"] = usage_doc(session=89, weekly=80, fable=89)
|
|
239
239
|
self.run_limits()
|
|
240
240
|
self.assertEqual(self.server.state["posts"], [])
|
|
241
241
|
self.assertFalse((self.account / ".usage-reset.json").exists())
|
|
242
242
|
self.assertEqual(self.limits_doc()["reset_credits_available"], 1)
|
|
243
243
|
|
|
244
|
-
def
|
|
245
|
-
# The
|
|
246
|
-
#
|
|
244
|
+
def test_a_week_parked_at_the_pools_threshold_is_reset(self) -> None:
|
|
245
|
+
# The live acct-15 shape (2026-09-22): weekly_all 93% parks the account for six
|
|
246
|
+
# days at the pool's 90% line; waiting for 95% left its reset unused.
|
|
247
|
+
self.server.state["usage"] = usage_doc(session=100, weekly=93, fable=64,
|
|
248
|
+
session_resets_in=5400)
|
|
249
|
+
self.server.state["responses"] = [(200, {"result": "reset", "resets_left": 0}, True)]
|
|
250
|
+
self.run_limits()
|
|
251
|
+
self.assertEqual(len(self.server.state["posts"]), 1)
|
|
252
|
+
self.assertFalse((self.account / ".limited").exists())
|
|
253
|
+
|
|
254
|
+
def park(self, bucket: str, lifts_in: int, reason: str = "client-rate-limit") -> Path:
|
|
255
|
+
marker = self.account / ".limited"
|
|
256
|
+
marked = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 600))
|
|
257
|
+
marker.write_text(f"{int(time.time()) + lifts_in}\nbucket={bucket} percent=100 "
|
|
258
|
+
f"marked_at={marked} reason={reason}\n", encoding="utf-8")
|
|
259
|
+
return marker
|
|
260
|
+
|
|
261
|
+
def test_a_park_the_telemetry_cannot_see_is_reset(self) -> None:
|
|
262
|
+
# acct-13 (a client five-hour 429 while OAuth read 0% everywhere) and acct-19 (the
|
|
263
|
+
# panel's weekly park while OAuth read 24%): parked is parked.
|
|
264
|
+
for bucket, lifts_in in (("client:five_hour", 5400),
|
|
265
|
+
("weekly_all", 3 * 86400), ("panel:observed", 86400)):
|
|
266
|
+
with self.subTest(bucket=bucket):
|
|
267
|
+
self.server.state["posts"] = []
|
|
268
|
+
self.server.state["usage"] = usage_doc(session=0, weekly=19, fable=24)
|
|
269
|
+
self.server.state["responses"] = [(200, {"result": "reset", "resets_left": 0}, True)]
|
|
270
|
+
(self.account / ".usage-reset.json").unlink(missing_ok=True)
|
|
271
|
+
(self.account / "limits.json").unlink(missing_ok=True)
|
|
272
|
+
marker = self.park(bucket, lifts_in)
|
|
273
|
+
self.run_limits()
|
|
274
|
+
self.assertEqual(len(self.server.state["posts"]), 1)
|
|
275
|
+
self.assertFalse(marker.exists())
|
|
276
|
+
|
|
277
|
+
def test_an_error_cooldown_is_not_a_usage_park(self) -> None:
|
|
278
|
+
self.park("session", 600, reason="error-cooldown")
|
|
279
|
+
self.server.state["usage"] = usage_doc(weekly=19, fable=24)
|
|
280
|
+
self.run_limits()
|
|
281
|
+
self.assertEqual(self.server.state["posts"], [])
|
|
282
|
+
|
|
283
|
+
def test_a_five_hour_wall_that_lifts_within_the_hour_keeps_its_reset(self) -> None:
|
|
247
284
|
self.server.state["usage"] = usage_doc(session=100, weekly=40, fable=40,
|
|
248
|
-
exhausted=["five_hour"])
|
|
285
|
+
exhausted=["five_hour"], session_resets_in=1800)
|
|
249
286
|
self.run_limits()
|
|
250
287
|
self.assertEqual(self.server.state["posts"], [])
|
|
288
|
+
self.assertIn("limit reset held", self.log())
|
|
251
289
|
self.assertTrue((self.account / ".limited").exists()) # still parked for the 5h
|
|
252
|
-
|
|
290
|
+
# ...but one that holds the account for longer is reset.
|
|
291
|
+
self.server.state["usage"] = usage_doc(session=100, weekly=40, fable=40,
|
|
292
|
+
exhausted=["five_hour"], session_resets_in=5400)
|
|
293
|
+
self.server.state["responses"] = [(200, {"result": "reset", "resets_left": 0}, True)]
|
|
294
|
+
self.run_limits()
|
|
295
|
+
self.assertEqual(len(self.server.state["posts"]), 1)
|
|
253
296
|
|
|
254
297
|
def test_a_five_hour_wall_beside_a_spent_week_is_redeemed(self) -> None:
|
|
255
298
|
# The live acct-17 shape of 2026-09-22: session 100% AND Fable weekly at 95%.
|
|
@@ -260,9 +303,9 @@ class ClaudeResetIntegrationTest(ClaudePoolSandbox, unittest.TestCase):
|
|
|
260
303
|
self.assertEqual(len(self.server.state["posts"]), 1)
|
|
261
304
|
self.assertFalse((self.account / ".limited").exists())
|
|
262
305
|
|
|
263
|
-
def
|
|
306
|
+
def test_a_week_that_refills_within_the_hour_keeps_its_reset(self) -> None:
|
|
264
307
|
self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"],
|
|
265
|
-
week_resets_in=
|
|
308
|
+
week_resets_in=1800)
|
|
266
309
|
self.run_limits()
|
|
267
310
|
self.assertEqual(self.server.state["posts"], [])
|
|
268
311
|
self.assertIn("limit reset held", self.log())
|
|
@@ -272,6 +315,13 @@ class ClaudeResetIntegrationTest(ClaudePoolSandbox, unittest.TestCase):
|
|
|
272
315
|
self.run_limits(CLAUDE_MULTIACC_RESET_MIN_HORIZON="0")
|
|
273
316
|
self.assertEqual(len(self.server.state["posts"]), 1)
|
|
274
317
|
|
|
318
|
+
def test_a_week_that_refills_in_eleven_hours_is_reset(self) -> None:
|
|
319
|
+
# acct-21 (2026-09-22): Fable at 95%, week back at 09:00 — out of service 11h.
|
|
320
|
+
self.server.state["usage"] = usage_doc(weekly=56, fable=95, week_resets_in=11 * 3600)
|
|
321
|
+
self.server.state["responses"] = [(200, {"result": "reset", "resets_left": 0}, True)]
|
|
322
|
+
self.run_limits()
|
|
323
|
+
self.assertEqual(len(self.server.state["posts"]), 1)
|
|
324
|
+
|
|
275
325
|
def test_a_grant_that_needs_the_limit_waits_for_it(self) -> None:
|
|
276
326
|
self.server.state["usage"] = usage_doc(fable=97, use_requires_limit=True)
|
|
277
327
|
self.run_limits()
|
|
@@ -140,18 +140,59 @@ class CodexResetIntegrationTest(CodexPoolSandbox, unittest.TestCase):
|
|
|
140
140
|
self.run_limits()
|
|
141
141
|
self.assertEqual(len(self.server.state["posts"]), 2)
|
|
142
142
|
|
|
143
|
-
def
|
|
143
|
+
def test_does_not_redeem_below_the_pools_threshold(self) -> None:
|
|
144
144
|
now = int(time.time())
|
|
145
145
|
self.server.state["usage"] = {"rate_limit": {"allowed": True,
|
|
146
|
-
"primary_window": {"used_percent":
|
|
146
|
+
"primary_window": {"used_percent": 89, "limit_window_seconds": 18000,
|
|
147
147
|
"reset_at": now + 3600}},
|
|
148
148
|
"rate_limit_reset_credits": {"available_count": 1}}
|
|
149
|
+
self.server.state["credits"] = {"available_count": 1, "credits": []}
|
|
149
150
|
result = self.run_limits()
|
|
150
151
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
151
152
|
self.assertEqual(self.server.state["credit_gets"], 1)
|
|
152
153
|
self.assertEqual(self.server.state["posts"], [])
|
|
153
154
|
self.assertFalse((self.pool / "acct-01/.usage-reset.json").exists())
|
|
154
155
|
|
|
156
|
+
def test_an_account_parked_at_the_pools_threshold_uses_its_reset(self) -> None:
|
|
157
|
+
# Operator rule (2026-09-22): a PARKED account uses its reset. The pool parks at
|
|
158
|
+
# 90%; waiting for 95% left accounts at 90-94% idle for days with credits unused.
|
|
159
|
+
now = int(time.time())
|
|
160
|
+
self.server.state["usage"] = {"rate_limit": {"allowed": True,
|
|
161
|
+
"primary_window": {"used_percent": 92, "limit_window_seconds": 604800,
|
|
162
|
+
"reset_at": now + 4 * 86400}}}
|
|
163
|
+
self.server.state["credits"] = {"available_count": 1, "credits": []}
|
|
164
|
+
self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 1})]
|
|
165
|
+
self.assertEqual(self.run_limits().returncode, 0)
|
|
166
|
+
self.assertEqual(len(self.server.state["posts"]), 1)
|
|
167
|
+
self.assertFalse((self.pool / "acct-01/.limited").exists())
|
|
168
|
+
|
|
169
|
+
def test_a_client_reported_park_uses_its_reset(self) -> None:
|
|
170
|
+
# The rollout scan parked the account on a real 429 its telemetry does not show.
|
|
171
|
+
now = int(time.time())
|
|
172
|
+
marker = self.pool / "acct-01/.limited"
|
|
173
|
+
marker.write_text(f"{now + 3 * 86400}\nbucket=client:7d percent=100 "
|
|
174
|
+
f"marked_at=2020-01-01T00:00:00Z reason=client-rate-limit\n")
|
|
175
|
+
self.server.state["usage"] = {"rate_limit": {"allowed": True,
|
|
176
|
+
"primary_window": {"used_percent": 40, "limit_window_seconds": 604800,
|
|
177
|
+
"reset_at": now + 3 * 86400}}}
|
|
178
|
+
self.server.state["credits"] = {"available_count": 1, "credits": []}
|
|
179
|
+
self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 1})]
|
|
180
|
+
self.assertEqual(self.run_limits().returncode, 0)
|
|
181
|
+
self.assertEqual(len(self.server.state["posts"]), 1)
|
|
182
|
+
self.assertFalse(marker.exists())
|
|
183
|
+
|
|
184
|
+
def test_a_park_that_lifts_within_the_hour_keeps_its_reset(self) -> None:
|
|
185
|
+
now = int(time.time())
|
|
186
|
+
(self.pool / "acct-01/.limited").write_text(
|
|
187
|
+
f"{now + 1200}\nbucket=client:5h percent=100 marked_at=2020-01-01T00:00:00Z "
|
|
188
|
+
f"reason=client-rate-limit\n")
|
|
189
|
+
self.server.state["usage"] = {"rate_limit": {"allowed": True,
|
|
190
|
+
"primary_window": {"used_percent": 40, "limit_window_seconds": 604800,
|
|
191
|
+
"reset_at": now + 3 * 86400}}}
|
|
192
|
+
self.server.state["credits"] = {"available_count": 1, "credits": []}
|
|
193
|
+
self.assertEqual(self.run_limits().returncode, 0)
|
|
194
|
+
self.assertEqual(self.server.state["posts"], [])
|
|
195
|
+
|
|
155
196
|
def test_retries_a_lost_response_with_the_same_idempotency_key(self) -> None:
|
|
156
197
|
now = int(time.time())
|
|
157
198
|
self.server.state["usage"] = {"rate_limit": {"allowed": False,
|