claude-multiacc 2.0.5 → 2.0.7
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/bin/claude-accounts +47 -8
- package/lib/__pycache__/audit.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/selector_policy.py +60 -1
- package/lib/selector_primitives.py +20 -1
- package/package.json +1 -1
- package/tests/run-tests.sh +65 -7
- package/tests/test_selector.py +100 -2
package/bin/claude-accounts
CHANGED
|
@@ -1273,6 +1273,7 @@ cmd_limits() {
|
|
|
1273
1273
|
import hashlib, json, os, sys, time, urllib.request
|
|
1274
1274
|
sys.path = [sys.argv[6]] + [p for p in sys.path if p not in ('', '.')]
|
|
1275
1275
|
import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
|
|
1276
|
+
from audit import creds_doc_state # noqa: E402 (one rule for "can this credential work")
|
|
1276
1277
|
|
|
1277
1278
|
root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
|
|
1278
1279
|
now = time.time()
|
|
@@ -1655,6 +1656,18 @@ for acct in manifest.get('accounts', []):
|
|
|
1655
1656
|
locked = store == 'locked'
|
|
1656
1657
|
if locked:
|
|
1657
1658
|
store = None
|
|
1659
|
+
# "This account has an OAuth login that could still work." A Keychain-held one
|
|
1660
|
+
# only the Mac's own launchd probe can open counts: it is unusable HERE, not
|
|
1661
|
+
# broken. A provably DEAD grant (no refresh token, or a refresh token that has
|
|
1662
|
+
# itself expired) does not count — for that account the setup token really is
|
|
1663
|
+
# the only bearer left, and it must still get its one try.
|
|
1664
|
+
oauth_alive = locked
|
|
1665
|
+
if store is not None:
|
|
1666
|
+
try:
|
|
1667
|
+
oauth_alive = creds_doc_state(store.read(), now)[0] == 'ok'
|
|
1668
|
+
except Exception:
|
|
1669
|
+
oauth_alive = True # unreadable is not proof of death: do not spend the token
|
|
1670
|
+
has_oauth = oauth_alive
|
|
1658
1671
|
if store is not None:
|
|
1659
1672
|
try:
|
|
1660
1673
|
c = store.read().get('claudeAiOauth', {})
|
|
@@ -1672,7 +1685,19 @@ for acct in manifest.get('accounts', []):
|
|
|
1672
1685
|
tok = None
|
|
1673
1686
|
if tok:
|
|
1674
1687
|
bearer, source = tok, 'oauth'
|
|
1675
|
-
|
|
1688
|
+
# A setup token is a LAST RESORT and only for an account that has no OAuth
|
|
1689
|
+
# credential at all. Where one exists but could not be used this pass — its
|
|
1690
|
+
# access token expired moments ago (refresh_oauth waits REFRESH_MIN_EXPIRED to
|
|
1691
|
+
# prove no live session owns it), a refresh backoff, or a Keychain this session
|
|
1692
|
+
# cannot open — spending the token is guaranteed to earn a 403 it can never not
|
|
1693
|
+
# earn, and that 403 used to park the whole ACCOUNT for six hours. Telemetry then
|
|
1694
|
+
# froze for an account whose OAuth would have worked on the very next pass, and
|
|
1695
|
+
# the shim ranked the pool on hour-old readings. Waiting for the next pass costs
|
|
1696
|
+
# minutes; the token costs six hours and answers nothing.
|
|
1697
|
+
if not bearer and has_oauth:
|
|
1698
|
+
say(f'{aid}: oauth credential not usable this pass; NOT spending the setup '
|
|
1699
|
+
f'token on a usage endpoint that always refuses it — retrying next pass')
|
|
1700
|
+
elif not bearer and os.path.isfile(tpath):
|
|
1676
1701
|
# Once this endpoint has refused THIS token file for lacking a scope, asking
|
|
1677
1702
|
# again is guaranteed to fail and only spends the account's hourly budget —
|
|
1678
1703
|
# which is how a permanent authorization problem disguised itself as a rate
|
|
@@ -1717,6 +1742,14 @@ for acct in manifest.get('accounts', []):
|
|
|
1717
1742
|
except Exception:
|
|
1718
1743
|
pass
|
|
1719
1744
|
|
|
1745
|
+
def save_prev():
|
|
1746
|
+
"""Persist the account's telemetry state as-is (no backoff, no invented
|
|
1747
|
+
freshness) — used when what failed says nothing about the ACCOUNT."""
|
|
1748
|
+
tmp = lpath + '.tmp'
|
|
1749
|
+
with open(tmp, 'w') as f:
|
|
1750
|
+
json.dump(prev, f, indent=1)
|
|
1751
|
+
os.replace(tmp, lpath)
|
|
1752
|
+
|
|
1720
1753
|
def park(wait, note):
|
|
1721
1754
|
"""Record a failed fetch WITHOUT inventing freshness: fetched_at is left
|
|
1722
1755
|
exactly as it was, so a parked account still reads as stale everywhere."""
|
|
@@ -1724,10 +1757,7 @@ for acct in manifest.get('accounts', []):
|
|
|
1724
1757
|
prev['backoff'] = wait
|
|
1725
1758
|
prev['last_error'] = note
|
|
1726
1759
|
prev['last_error_at'] = int(now)
|
|
1727
|
-
|
|
1728
|
-
with open(tmp, 'w') as f:
|
|
1729
|
-
json.dump(prev, f, indent=1)
|
|
1730
|
-
os.replace(tmp, lpath)
|
|
1760
|
+
save_prev()
|
|
1731
1761
|
|
|
1732
1762
|
if e.code == 429:
|
|
1733
1763
|
# Respect Retry-After; otherwise exponential backoff capped at 30 min.
|
|
@@ -1765,8 +1795,16 @@ for acct in manifest.get('accounts', []):
|
|
|
1765
1795
|
tdigest = token_digest(tpath)
|
|
1766
1796
|
if tdigest:
|
|
1767
1797
|
prev['token_scope_denied'] = tdigest
|
|
1768
|
-
|
|
1769
|
-
|
|
1798
|
+
if scope_denied and source == 'token':
|
|
1799
|
+
# A refusal of the TOKEN is a fact about that credential, not about
|
|
1800
|
+
# the account: parking the account here also blocked the OAuth path,
|
|
1801
|
+
# so an account that merely missed one refresh went dark for six
|
|
1802
|
+
# hours. The digest above already stops this token being spent again;
|
|
1803
|
+
# leave the account free to try its OAuth login next pass.
|
|
1804
|
+
save_prev()
|
|
1805
|
+
else:
|
|
1806
|
+
park(wait, f'HTTP {e.code} (source={source})'
|
|
1807
|
+
+ (' — permanent, server said do not retry' if denied else ''))
|
|
1770
1808
|
if scope_denied and source == 'token':
|
|
1771
1809
|
# The exact shape of this outage: a setup token is minted WITHOUT the
|
|
1772
1810
|
# user:profile scope the usage endpoint requires, so an account whose
|
|
@@ -1775,7 +1813,8 @@ for acct in manifest.get('accounts', []):
|
|
|
1775
1813
|
say(f'{aid}: usage endpoint refuses the setup token (HTTP {e.code} — a '
|
|
1776
1814
|
f'setup token has no user:profile scope). Telemetry is DEAD for this '
|
|
1777
1815
|
f'account until it has an OAuth login here: claude-accounts login {aid}. '
|
|
1778
|
-
f'
|
|
1816
|
+
f'This token will not be offered again; the ACCOUNT is not parked, so '
|
|
1817
|
+
f'an OAuth login works the moment it lands.')
|
|
1779
1818
|
elif denied:
|
|
1780
1819
|
say(f'{aid}: usage fetch refused for good (HTTP {e.code}, source={source}); '
|
|
1781
1820
|
f'backing off {wait}s — re-login needed: claude-accounts login {aid}')
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/selector_policy.py
CHANGED
|
@@ -6,6 +6,7 @@ from datetime import datetime
|
|
|
6
6
|
from decimal import Decimal, localcontext
|
|
7
7
|
|
|
8
8
|
from selector_primitives import (
|
|
9
|
+
DEFAULT_HEADROOM_BAND,
|
|
9
10
|
SCHEMA,
|
|
10
11
|
SELECTOR_VERSION,
|
|
11
12
|
canonical_sha256,
|
|
@@ -125,6 +126,47 @@ def _winner_key(row: dict) -> tuple:
|
|
|
125
126
|
row["engine"], row["account_id"], row["runner_id"], row["runner_generation"])
|
|
126
127
|
|
|
127
128
|
|
|
129
|
+
def _band_floor(rows: list[dict], band: Decimal) -> Decimal | None:
|
|
130
|
+
"""The lowest headroom still counted as "as good as the best" this round.
|
|
131
|
+
|
|
132
|
+
Ranking strictly by headroom hands every task to whichever account is on top,
|
|
133
|
+
which is precisely how one account's limit gets burned to zero while three
|
|
134
|
+
others idle: the runner-up only ever wins after the leader has been spent
|
|
135
|
+
below it. Accounts within ``band`` points of the leader are treated as equally
|
|
136
|
+
good, and the tie-break below spreads work across them.
|
|
137
|
+
"""
|
|
138
|
+
known = [Decimal(row["effective_headroom"]) for row in rows
|
|
139
|
+
if row["quota_known"] and row["effective_headroom"] is not None]
|
|
140
|
+
return max(known) - band if known else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _spread(reservation_key: str, row: dict) -> str:
|
|
144
|
+
"""A stable pseudo-random ordinal for this row IN THIS REQUEST.
|
|
145
|
+
|
|
146
|
+
Real randomness cannot live here — the response carries a reproducible
|
|
147
|
+
digest of its own inputs, and two runs of the same request must agree. A
|
|
148
|
+
digest over (reservation key, identity) gives every task its own order over
|
|
149
|
+
the band while staying a pure function of the request, so a burst of
|
|
150
|
+
parallel launches spreads instead of stacking on one account.
|
|
151
|
+
"""
|
|
152
|
+
return canonical_sha256({"reservation_key": reservation_key,
|
|
153
|
+
"identity": list(_row_identity(row))})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _band_key(reservation_key: str):
|
|
157
|
+
"""Order INSIDE the band: least-recently-used first, then the spread digest.
|
|
158
|
+
|
|
159
|
+
Headroom deliberately does not appear — inside the band it is what we are
|
|
160
|
+
choosing to ignore. LRU is what makes this rotate rather than merely jitter:
|
|
161
|
+
with three banded accounts, three sequential tasks touch all three.
|
|
162
|
+
"""
|
|
163
|
+
def key(row: dict) -> tuple:
|
|
164
|
+
return (row["last_selected_at"] is not None, row["last_selected_at"] or "",
|
|
165
|
+
_spread(reservation_key, row),
|
|
166
|
+
row["engine"], row["account_id"], row["runner_id"], row["runner_generation"])
|
|
167
|
+
return key
|
|
168
|
+
|
|
169
|
+
|
|
128
170
|
def _snapshot_key(row: dict) -> tuple:
|
|
129
171
|
if row["identity_valid"]:
|
|
130
172
|
return 0, row["runner_id"], row["runner_generation"], row["engine"], row["account_id"]
|
|
@@ -154,7 +196,17 @@ def select(request: object) -> dict:
|
|
|
154
196
|
eligible = [row for row in rows if row["eligible"]]
|
|
155
197
|
if not eligible:
|
|
156
198
|
return _error("no_candidate", {"eligible_count": 0})
|
|
157
|
-
|
|
199
|
+
band, band_text = decimal_value(request.get("headroom_band", DEFAULT_HEADROOM_BAND))
|
|
200
|
+
floor = _band_floor(eligible, band) if band is not None else None
|
|
201
|
+
if floor is None:
|
|
202
|
+
# No usable quota anywhere: nothing to band, so keep the plain ordering
|
|
203
|
+
# (which already ranks unknown-quota rows last and rotates on ties).
|
|
204
|
+
banded = eligible
|
|
205
|
+
winner = min(banded, key=_winner_key)
|
|
206
|
+
else:
|
|
207
|
+
banded = [row for row in eligible if row["quota_known"]
|
|
208
|
+
and Decimal(row["effective_headroom"]) >= floor]
|
|
209
|
+
winner = min(banded, key=_band_key(request["reservation_key"]))
|
|
158
210
|
rows.sort(key=_snapshot_key)
|
|
159
211
|
snapshot = canonical_sha256(rows)
|
|
160
212
|
chosen = dict(zip(("runner_id", "runner_generation", "engine", "account_id"), _row_identity(winner)))
|
|
@@ -162,6 +214,8 @@ def select(request: object) -> dict:
|
|
|
162
214
|
"schema": SCHEMA, "selector_version": SELECTOR_VERSION,
|
|
163
215
|
"database_now": canonical_now, "policy": request["policy"],
|
|
164
216
|
"required_engine": request.get("required_engine"), "reservation_key": request["reservation_key"],
|
|
217
|
+
# The band changes which account wins, so it belongs in the proof.
|
|
218
|
+
"headroom_band": band_text,
|
|
165
219
|
"candidate_snapshot_digest": snapshot, "selected_identity": chosen}
|
|
166
220
|
score_fields = ("quota_known", "weekly_pct", "session_pct", "weekly_remaining",
|
|
167
221
|
"session_remaining", "effective_headroom", "last_selected_at")
|
|
@@ -169,6 +223,11 @@ def select(request: object) -> dict:
|
|
|
169
223
|
"score_basis": {name: winner[name] for name in score_fields},
|
|
170
224
|
"eligible_count": len(eligible),
|
|
171
225
|
"eligible_alternative_count": alternatives,
|
|
226
|
+
# Observability: which accounts were considered interchangeable,
|
|
227
|
+
# so "why did it not pick the emptiest one" has an answer.
|
|
228
|
+
"headroom_band": band_text,
|
|
229
|
+
"band_floor": format(floor, "f") if floor is not None else None,
|
|
230
|
+
"band_count": len(banded),
|
|
172
231
|
"candidate_snapshot_digest": snapshot,
|
|
173
232
|
"selection_digest": canonical_sha256(digest_input)}
|
|
174
233
|
if request["policy"] == "reviewer" and not alternatives and _row_identity(winner) == producer:
|
|
@@ -11,6 +11,13 @@ from datetime import datetime, timezone
|
|
|
11
11
|
from decimal import Decimal, InvalidOperation
|
|
12
12
|
|
|
13
13
|
SCHEMA = "claude-multiacc/pool-selection.v2"
|
|
14
|
+
# NOT bumped for the headroom band. This string is a BUILD FENCE, not a changelog:
|
|
15
|
+
# a queued task records the version that reserved its account, and the claim function
|
|
16
|
+
# (agent-sdk 0005_delivery_claim_functions.sql) only lets a runner advertising the same
|
|
17
|
+
# string claim it — while the runner's value is a hardcoded constant shipped in its
|
|
18
|
+
# bundle. With Macs routinely several builds behind, bumping this stops every lagging
|
|
19
|
+
# Mac from claiming any new work until it updates. The band is additive and optional:
|
|
20
|
+
# the request shape, the response shape and every consumer are unchanged.
|
|
14
21
|
SELECTOR_VERSION = "2.0.1"
|
|
15
22
|
TIME_RE = re.compile(
|
|
16
23
|
r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-5][0-9]"
|
|
@@ -25,6 +32,10 @@ CANDIDATE_KEYS = IDENTITY_KEYS | {
|
|
|
25
32
|
REQUEST_KEYS = {
|
|
26
33
|
"schema", "database_now", "policy", "required_engine", "producer_identity",
|
|
27
34
|
"excluded_identities", "candidates", "reservation_key"}
|
|
35
|
+
# Optional because a caller pinned to an older panel build still sends exactly
|
|
36
|
+
# REQUEST_KEYS; absent, the policy applies its own default band.
|
|
37
|
+
OPTIONAL_REQUEST_KEYS = {"headroom_band"}
|
|
38
|
+
DEFAULT_HEADROOM_BAND = "30"
|
|
28
39
|
POLICIES = {
|
|
29
40
|
"default_claude", "default_codex", "default_both", "explicit", "producer_retry", "reviewer"}
|
|
30
41
|
|
|
@@ -166,8 +177,16 @@ def request_error(request: object) -> tuple[str, dict] | None:
|
|
|
166
177
|
return "invalid_request", {"field": "schema"}
|
|
167
178
|
if request.get("schema") != SCHEMA:
|
|
168
179
|
return "unsupported_schema", {"field": "schema"}
|
|
169
|
-
|
|
180
|
+
keys = set(request)
|
|
181
|
+
if not REQUEST_KEYS <= keys or keys - REQUEST_KEYS - OPTIONAL_REQUEST_KEYS:
|
|
170
182
|
return "invalid_request", {"field": "$"}
|
|
183
|
+
if "headroom_band" in request:
|
|
184
|
+
band, _text = decimal_value(request["headroom_band"])
|
|
185
|
+
# decimal_value clamps to [0,100] and rejects non-numeric shapes; a band
|
|
186
|
+
# the caller cannot express exactly must fail loudly rather than silently
|
|
187
|
+
# widen selection to the whole pool.
|
|
188
|
+
if band is None or isinstance(request["headroom_band"], bool):
|
|
189
|
+
return "invalid_request", {"field": "headroom_band"}
|
|
171
190
|
try:
|
|
172
191
|
timestamp(request["database_now"])
|
|
173
192
|
except ValueError:
|
package/package.json
CHANGED
package/tests/run-tests.sh
CHANGED
|
@@ -1791,17 +1791,21 @@ EOF
|
|
|
1791
1791
|
claude-accounts limits --force 2>&1)"
|
|
1792
1792
|
check "a scope-denied 403 names the missing scope" "user:profile" "$out"
|
|
1793
1793
|
check "a scope-denied 403 names the ceremony that fixes it" "claude-accounts login acct-01" "$out"
|
|
1794
|
-
|
|
1794
|
+
# The CREDENTIAL is refused for good (the digest below stops it being offered
|
|
1795
|
+
# again); the ACCOUNT is deliberately NOT parked, because parking it also blocked
|
|
1796
|
+
# the OAuth path and froze telemetry for hours on accounts whose login was fine.
|
|
1797
|
+
check "a scope-denied 403 retires the token, not the account" \
|
|
1798
|
+
"This token will not be offered again" "$out"
|
|
1795
1799
|
python3 - "$SD/acct-01/limits.json" "$now" <<'EOF'
|
|
1796
1800
|
import json, sys
|
|
1797
1801
|
lim = json.load(open(sys.argv[1]))
|
|
1798
1802
|
now = int(sys.argv[2])
|
|
1799
|
-
assert lim
|
|
1803
|
+
assert lim.get('token_scope_denied'), lim # THIS token is retired
|
|
1804
|
+
assert not lim.get('retry_after'), lim # ...but the account is not parked
|
|
1800
1805
|
assert lim['fetched_at'] == now - 950000, lim # a FAILURE never invents freshness
|
|
1801
|
-
assert 'HTTP 403' in lim['last_error'], lim
|
|
1802
1806
|
EOF
|
|
1803
|
-
[ $? -eq 0 ] && t_ok "a refused
|
|
1804
|
-
|| t_fail "403
|
|
1807
|
+
[ $? -eq 0 ] && t_ok "a refused token is retired without parking the account, keeping its stale fetched_at" \
|
|
1808
|
+
|| t_fail "403 credential-scoped state" "see $SD/acct-01/limits.json"
|
|
1805
1809
|
|
|
1806
1810
|
# The whole point: the next scheduled pass must NOT spend another request. Before the
|
|
1807
1811
|
# fix this retried every five minutes, from every machine, forever.
|
|
@@ -1809,9 +1813,12 @@ EOF
|
|
|
1809
1813
|
out="$(CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
|
|
1810
1814
|
claude-accounts limits 2>&1)"
|
|
1811
1815
|
after="$(wc -l < "$uhits")"
|
|
1812
|
-
[ "$before" = "$after" ] && t_ok "a
|
|
1816
|
+
[ "$before" = "$after" ] && t_ok "a retired token is not re-offered on the next pass" \
|
|
1813
1817
|
|| t_fail "403 retry storm" "endpoint hit again ($before -> $after requests)"
|
|
1814
|
-
|
|
1818
|
+
# It is the CREDENTIAL that is spent, so the message names the ceremony that
|
|
1819
|
+
# replaces it rather than a clock the operator would otherwise sit and watch.
|
|
1820
|
+
check "and says what would fix it, not how long to wait" \
|
|
1821
|
+
"telemetry stays dark until: claude-accounts login acct-01" "$out"
|
|
1815
1822
|
|
|
1816
1823
|
# Any other non-2xx backs off too — a 5xx retried every pass is the same storm.
|
|
1817
1824
|
rm -f "$SD/acct-01/limits.json"
|
|
@@ -1990,6 +1997,57 @@ EOF
|
|
|
1990
1997
|
[ "$before" = "$after" ] && t_ok "a token already refused for scope is not offered again" \
|
|
1991
1998
|
|| t_fail "token re-offered" "endpoint hit again ($before -> $after)"
|
|
1992
1999
|
check "and the message says what would fix it" "claude-accounts login acct-01" "$out"
|
|
2000
|
+
# ---- an OAuth account NEVER spends its setup token here, and a token refusal
|
|
2001
|
+
# ---- never parks the ACCOUNT ------------------------------------------------
|
|
2002
|
+
# Live symptom (my-mini, 2026-08-28): acct-02/acct-05 sat 1-5 HOURS stale with
|
|
2003
|
+
# "backing off after HTTP 403 (source=token) — permanent" while their OAuth
|
|
2004
|
+
# credentials were fine. The chain: the access token had expired minutes ago, so
|
|
2005
|
+
# refresh_oauth declined (REFRESH_MIN_EXPIRED proves no live session owns it),
|
|
2006
|
+
# the probe fell through to the setup token, the endpoint refused it for scope,
|
|
2007
|
+
# and that parked the whole ACCOUNT for six hours — blocking the OAuth path that
|
|
2008
|
+
# would have worked on the very next pass. The shim then ranked the pool on
|
|
2009
|
+
# hour-old readings and said so ("usage telemetry is 1h old").
|
|
2010
|
+
SD2="$WORK/token-poison"
|
|
2011
|
+
mkdir -p "$SD2/acct-01" "$SD2/tmp"
|
|
2012
|
+
: > "$SD2/.limits-kick"
|
|
2013
|
+
cat > "$SD2/accounts.json" <<EOF
|
|
2014
|
+
{ "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
|
|
2015
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
2016
|
+
"accounts": [ {"id": "acct-01", "email": "poison@test", "home": "mac",
|
|
2017
|
+
"added_at": "2026-08-28T00:00:00Z"} ] }
|
|
2018
|
+
EOF
|
|
2019
|
+
# An OAuth credential whose ACCESS token expired a moment ago: too recent for
|
|
2020
|
+
# refresh_oauth to touch, so this pass has no bearer it may use.
|
|
2021
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-justexpired","refreshToken":"r","expiresAt":%s000,"refreshTokenExpiresAt":9999999999999}}' \
|
|
2022
|
+
"$((now - 30))" > "$SD2/acct-01/.credentials.json"
|
|
2023
|
+
printf 'sk-ant-oat01-PORTABLE0001\n' > "$SD2/acct-01/server.token"
|
|
2024
|
+
before="$(wc -l < "$uhits")"
|
|
2025
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$SD2" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
|
|
2026
|
+
claude-accounts limits 2>&1)"
|
|
2027
|
+
after="$(wc -l < "$uhits")"
|
|
2028
|
+
[ "$before" = "$after" ] \
|
|
2029
|
+
&& t_ok "an OAuth account never spends its setup token on the usage endpoint" \
|
|
2030
|
+
|| t_fail "token spent" "the endpoint was called ($before -> $after) with a token that can only 403"
|
|
2031
|
+
check "and it says it will simply retry" "retrying next pass" "$out"
|
|
2032
|
+
[ ! -f "$SD2/acct-01/limits.json" ] \
|
|
2033
|
+
&& t_ok "no six-hour park is written for an account that merely missed a refresh" \
|
|
2034
|
+
|| t_fail "account parked" "$(cat "$SD2/acct-01/limits.json")"
|
|
2035
|
+
|
|
2036
|
+
# A token-only account still tries once (that is the only way to learn), but the
|
|
2037
|
+
# refusal must park the CREDENTIAL, not the account: the digest is remembered and
|
|
2038
|
+
# no retry_after is written, so a later OAuth login is free to work immediately.
|
|
2039
|
+
rm -f "$SD2/acct-01/.credentials.json" "$SD2/acct-01/limits.json"
|
|
2040
|
+
CLAUDE_ACCOUNTS_ROOT="$SD2" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
|
|
2041
|
+
claude-accounts limits >/dev/null 2>&1
|
|
2042
|
+
python3 - "$SD2/acct-01/limits.json" <<'EOF'
|
|
2043
|
+
import json, sys
|
|
2044
|
+
d = json.load(open(sys.argv[1]))
|
|
2045
|
+
assert d.get('token_scope_denied'), d
|
|
2046
|
+
assert not d.get('retry_after'), f"the account was parked by a credential refusal: {d}"
|
|
2047
|
+
EOF
|
|
2048
|
+
[ $? -eq 0 ] && t_ok "a token scope refusal parks the credential, never the account" \
|
|
2049
|
+
|| t_fail "token refusal parked the account" "see $SD2/acct-01/limits.json"
|
|
2050
|
+
|
|
1993
2051
|
# Re-minting the token is a new credential, so it earns a fresh try.
|
|
1994
2052
|
printf 'sk-ant-oat01-REMINTED01\n' > "$SD/acct-01/server.token"
|
|
1995
2053
|
before="$(wc -l < "$uhits")"
|
package/tests/test_selector.py
CHANGED
|
@@ -54,7 +54,7 @@ class SelectorTests(unittest.TestCase):
|
|
|
54
54
|
"e8ef0b4bc7b95e76231ee6da79616d92bd9251c1c991845c8c0524fcbfb6593a")
|
|
55
55
|
self.assertEqual(
|
|
56
56
|
response["selection_digest"],
|
|
57
|
-
"
|
|
57
|
+
"8b68d69b31b2d2252a16f7b83ac46860987647aa360402caa815b2a1bd468def")
|
|
58
58
|
|
|
59
59
|
def test_unicode_and_active_reservation_proof(self):
|
|
60
60
|
busy = {"active_expires_at": "2026-08-25T09:00:00.000001Z", "last_selected_at": None}
|
|
@@ -68,7 +68,7 @@ class SelectorTests(unittest.TestCase):
|
|
|
68
68
|
"29e8e7d1a611f38ee0be43622102b33875e7ff75ff9024aa43d16151575071d4")
|
|
69
69
|
self.assertEqual(
|
|
70
70
|
response["selection_digest"],
|
|
71
|
-
"
|
|
71
|
+
"72e7b31a00496fff0f6281f4dacd656d3a2a1fc438038e2c20c49274e1f61068")
|
|
72
72
|
|
|
73
73
|
def test_both_chooses_each_provider_and_survives_provider_loss(self):
|
|
74
74
|
codex = select(_request([
|
|
@@ -207,8 +207,106 @@ class SelectorTests(unittest.TestCase):
|
|
|
207
207
|
for item in bad))
|
|
208
208
|
self.assertEqual(json.loads(decimal_response.stdout)["account_id"], "z-lower")
|
|
209
209
|
self.assertEqual(version, "2.0.1")
|
|
210
|
+
# npm package versioning, unrelated to SELECTOR_VERSION: auto-version bumps the
|
|
211
|
+
# next patch above what is published.
|
|
210
212
|
self.assertEqual(versions, ["2.0.0", "2.0.1"])
|
|
211
213
|
|
|
212
214
|
|
|
215
|
+
class HeadroomBandTests(unittest.TestCase):
|
|
216
|
+
"""Accounts within `headroom_band` points of the leader are interchangeable.
|
|
217
|
+
|
|
218
|
+
Ranking strictly by headroom sends every task to whichever account is on top
|
|
219
|
+
until it is spent below the runner-up, which is how one account's weekly limit
|
|
220
|
+
was burned to zero while three others sat idle.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def _pool(self, *specs) -> list[dict]:
|
|
224
|
+
# spec: (account_id, weekly_pct, last_selected_at)
|
|
225
|
+
return [_candidate(account_id=name, weekly_pct=pct, session_pct=0,
|
|
226
|
+
reservation_history={"active_expires_at": None,
|
|
227
|
+
"last_selected_at": last})
|
|
228
|
+
for name, pct, last in specs]
|
|
229
|
+
|
|
230
|
+
def test_band_spreads_launches_instead_of_stacking_on_the_leader(self):
|
|
231
|
+
# headroom 100 / 80 / 70 / 60 — the first three are within 30 of the leader.
|
|
232
|
+
pool = self._pool(("a", 0, None), ("b", 20, None), ("c", 30, None), ("d", 40, None))
|
|
233
|
+
picked = {}
|
|
234
|
+
for index in range(120):
|
|
235
|
+
response = select(_request(pool, reservation_key=f"launch-{index}"))
|
|
236
|
+
self.assertTrue(response["ok"])
|
|
237
|
+
picked[response["account_id"]] = picked.get(response["account_id"], 0) + 1
|
|
238
|
+
self.assertEqual(response["band_floor"], "70")
|
|
239
|
+
self.assertEqual(response["band_count"], 3)
|
|
240
|
+
self.assertEqual(set(picked), {"a", "b", "c"},
|
|
241
|
+
"every banded account must take work; 'd' is out of band")
|
|
242
|
+
# No account may take more than half: the point is spreading the burn.
|
|
243
|
+
self.assertLess(max(picked.values()), 60, picked)
|
|
244
|
+
self.assertGreater(min(picked.values()), 20, picked)
|
|
245
|
+
|
|
246
|
+
def test_same_request_always_selects_the_same_account(self):
|
|
247
|
+
pool = self._pool(("a", 0, None), ("b", 20, None), ("c", 30, None))
|
|
248
|
+
first = select(_request(pool, reservation_key="stable"))
|
|
249
|
+
for _ in range(5):
|
|
250
|
+
repeat = select(_request(pool, reservation_key="stable"))
|
|
251
|
+
self.assertEqual(repeat["account_id"], first["account_id"])
|
|
252
|
+
self.assertEqual(repeat["selection_digest"], first["selection_digest"])
|
|
253
|
+
|
|
254
|
+
def test_least_recently_used_wins_inside_the_band(self):
|
|
255
|
+
# The emptiest account (a) was used most recently; the band rotates past it.
|
|
256
|
+
pool = self._pool(("a", 0, "2026-08-25T08:59:00.000000Z"),
|
|
257
|
+
("b", 20, "2026-08-25T08:00:00.000000Z"),
|
|
258
|
+
("c", 30, "2026-08-25T07:00:00.000000Z"))
|
|
259
|
+
self.assertEqual(select(_request(pool))["account_id"], "c")
|
|
260
|
+
# A never-used account outranks every used one.
|
|
261
|
+
pool = self._pool(("a", 0, "2026-08-25T08:59:00.000000Z"), ("b", 20, None))
|
|
262
|
+
self.assertEqual(select(_request(pool))["account_id"], "b")
|
|
263
|
+
|
|
264
|
+
def test_out_of_band_account_never_wins_however_idle(self):
|
|
265
|
+
pool = self._pool(("a", 0, "2026-08-25T08:59:59.000000Z"),
|
|
266
|
+
("d", 40, "2020-01-01T00:00:00.000000Z"))
|
|
267
|
+
response = select(_request(pool))
|
|
268
|
+
self.assertEqual(response["account_id"], "a")
|
|
269
|
+
self.assertEqual(response["band_count"], 1)
|
|
270
|
+
|
|
271
|
+
def test_zero_band_restores_strict_most_headroom(self):
|
|
272
|
+
pool = self._pool(("a", 0, "2026-08-25T08:59:00.000000Z"), ("b", 20, None))
|
|
273
|
+
self.assertEqual(select(_request(pool, headroom_band=0))["account_id"], "a")
|
|
274
|
+
|
|
275
|
+
def test_band_is_configurable_and_validated(self):
|
|
276
|
+
pool = self._pool(("a", 0, None), ("d", 40, None))
|
|
277
|
+
wide = select(_request(pool, headroom_band="45"))
|
|
278
|
+
self.assertEqual(wide["band_floor"], "55")
|
|
279
|
+
self.assertEqual(wide["band_count"], 2)
|
|
280
|
+
for bad in ("abc", True, [], "1e5", None):
|
|
281
|
+
refusal = select(_request(pool, headroom_band=bad))
|
|
282
|
+
self.assertFalse(refusal["ok"], bad)
|
|
283
|
+
self.assertEqual(refusal["error_detail"], {"field": "headroom_band"}, bad)
|
|
284
|
+
|
|
285
|
+
def test_unknown_quota_never_enters_the_band(self):
|
|
286
|
+
# A row with no telemetry has no headroom to compare; it must not become
|
|
287
|
+
# "as good as the leader" just because the band is generous.
|
|
288
|
+
pool = self._pool(("a", 0, None))
|
|
289
|
+
pool.append(_candidate(account_id="blind", weekly_pct=None, session_pct=None,
|
|
290
|
+
reservation_history={"active_expires_at": None,
|
|
291
|
+
"last_selected_at": None}))
|
|
292
|
+
response = select(_request(pool))
|
|
293
|
+
self.assertEqual(response["account_id"], "a")
|
|
294
|
+
self.assertEqual(response["band_count"], 1)
|
|
295
|
+
# ...and with NO usable telemetry anywhere, the band cannot apply at all.
|
|
296
|
+
blind = [_candidate(account_id=name, weekly_pct=None, session_pct=None)
|
|
297
|
+
for name in ("x", "y")]
|
|
298
|
+
response = select(_request(blind))
|
|
299
|
+
self.assertTrue(response["ok"])
|
|
300
|
+
self.assertIsNone(response["band_floor"])
|
|
301
|
+
|
|
302
|
+
def test_band_participates_in_the_selection_proof(self):
|
|
303
|
+
pool = self._pool(("a", 0, None), ("b", 20, None))
|
|
304
|
+
narrow = select(_request(pool, headroom_band="5"))
|
|
305
|
+
wide = select(_request(pool, headroom_band="50"))
|
|
306
|
+
self.assertNotEqual(narrow["selection_digest"], wide["selection_digest"])
|
|
307
|
+
# An omitted band is the documented default, and says so in the response.
|
|
308
|
+
self.assertEqual(select(_request(pool))["headroom_band"], "30")
|
|
309
|
+
|
|
310
|
+
|
|
213
311
|
if __name__ == "__main__":
|
|
214
312
|
unittest.main()
|