claude-multiacc 2.0.17 → 2.0.19
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 +19 -2
- package/bin/codex-accounts +11 -2
- 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/package.json +1 -1
- package/tests/run-tests.sh +64 -0
- package/tests/test_codex_reset.py +66 -1
package/bin/claude-accounts
CHANGED
|
@@ -2114,6 +2114,10 @@ for acct in manifest.get('accounts', []):
|
|
|
2114
2114
|
'percent': pct,
|
|
2115
2115
|
'resets_at': resets,
|
|
2116
2116
|
'resets_epoch': int(parse_iso(resets) or now + 3600),
|
|
2117
|
+
# Present only for a bucket that belongs to ONE model: such a
|
|
2118
|
+
# bucket parks that model, never the account (see the marker
|
|
2119
|
+
# choice below and the shim's scoped_blocks_run).
|
|
2120
|
+
'model': model,
|
|
2117
2121
|
})
|
|
2118
2122
|
if not buckets:
|
|
2119
2123
|
# Fallback for a payload with no limits[] array: scan EVERY top-level object
|
|
@@ -2175,8 +2179,21 @@ for acct in manifest.get('accounts', []):
|
|
|
2175
2179
|
offenders = [b for b in buckets if b['percent'] >= threshold]
|
|
2176
2180
|
mpath = os.path.join(d, '.limited')
|
|
2177
2181
|
if offenders:
|
|
2178
|
-
|
|
2179
|
-
|
|
2182
|
+
# ONE bucket, described consistently: the marker's epoch is the reset of
|
|
2183
|
+
# the bucket its detail line names. It used to pair the highest PERCENT
|
|
2184
|
+
# with the LATEST reset over every offender, and the two came from
|
|
2185
|
+
# different buckets: a 100% session bucket resetting in a minute was
|
|
2186
|
+
# written with a Fable weekly bucket's epoch six days out, and everything
|
|
2187
|
+
# that trusts the marker (this shim, app-robot's fleet verdict on every
|
|
2188
|
+
# Mac) parked the whole account for six days over a five-hour window
|
|
2189
|
+
# (2026-08-29, acct-05). An account-level bucket wins over a model-scoped
|
|
2190
|
+
# one — the latter parks a model, not the account (scoped_blocks_run) —
|
|
2191
|
+
# and among those, the longest-lived exclusion, since any bucket over the
|
|
2192
|
+
# threshold stays there until its own reset.
|
|
2193
|
+
account_level = [b for b in offenders if not b.get('model')]
|
|
2194
|
+
worst = max(account_level or offenders,
|
|
2195
|
+
key=lambda b: (int(b['resets_epoch']), b['percent']))
|
|
2196
|
+
reset_epoch = int(worst['resets_epoch'])
|
|
2180
2197
|
# Atomic: a concurrent shim must never read a half-written marker.
|
|
2181
2198
|
with open(mpath + '.tmp', 'w') as f:
|
|
2182
2199
|
f.write(f'{reset_epoch}\n')
|
package/bin/codex-accounts
CHANGED
|
@@ -1492,8 +1492,17 @@ for acct in manifest.get('accounts', []):
|
|
|
1492
1492
|
b for b in buckets if b['percent'] >= threshold]
|
|
1493
1493
|
mpath = os.path.join(d, '.limited')
|
|
1494
1494
|
if offenders:
|
|
1495
|
-
|
|
1496
|
-
|
|
1495
|
+
# ONE bucket, described consistently: the marker's epoch is the reset of
|
|
1496
|
+
# the bucket its detail line names. It used to pair the highest PERCENT
|
|
1497
|
+
# with the LATEST reset over every offender, and the two came from
|
|
1498
|
+
# different buckets — a 100% five-hour window resetting tonight written
|
|
1499
|
+
# with a seven-day epoch — so everything that trusts the marker parked
|
|
1500
|
+
# the account for days over a window that refills in hours. #20 fixed
|
|
1501
|
+
# this for the claude pool and left the codex writer behind; app-robot
|
|
1502
|
+
# reads both with the same rule. Among offenders the longest-lived wins,
|
|
1503
|
+
# since any bucket over the threshold stays there until its own reset.
|
|
1504
|
+
worst = max(offenders, key=lambda b: (int(b['resets_epoch']), b['percent']))
|
|
1505
|
+
reset_epoch = int(worst['resets_epoch'])
|
|
1497
1506
|
# Atomic: a concurrent shim must never read a half-written marker.
|
|
1498
1507
|
with open(mpath + '.tmp', 'w') as f:
|
|
1499
1508
|
f.write(f'{reset_epoch}\n')
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/tests/run-tests.sh
CHANGED
|
@@ -1883,6 +1883,70 @@ sc="$(python3 -c "import json;d=json.load(open('$ACC/acct-01/limits.json'));prin
|
|
|
1883
1883
|
|| t_fail "ranking score" "got $sc"
|
|
1884
1884
|
rm -f "$ACC/acct-01/limits.json" "$ACC/acct-01/.limited"
|
|
1885
1885
|
|
|
1886
|
+
# ---- 16-marker. the marker names ONE bucket and carries THAT bucket's reset ----------
|
|
1887
|
+
# A 100% session bucket (resets in an hour) beside a 100% Fable-only weekly bucket
|
|
1888
|
+
# (resets in five days) used to produce "bucket=session … resets_at=<+1h>" on line 2
|
|
1889
|
+
# under a line-1 epoch five days out — the highest percent paired with the latest
|
|
1890
|
+
# reset of a DIFFERENT bucket. Every reader of the marker (this shim, app-robot's
|
|
1891
|
+
# fleet-wide verdict) then parked the whole account for five days over a five-hour
|
|
1892
|
+
# window (2026-08-29, acct-05). The account-level bucket is named with its own reset.
|
|
1893
|
+
cat > "$WORK/usage-session-vs-fable.json" <<'EOF2'
|
|
1894
|
+
{"limits":[
|
|
1895
|
+
{"kind":"session","percent":100,"resets_at":"2099-01-01T01:00:00+00:00","scope":null},
|
|
1896
|
+
{"kind":"weekly_all","percent":40,"resets_at":"2099-01-05T00:00:00+00:00","scope":null},
|
|
1897
|
+
{"kind":"weekly_scoped","percent":100,"resets_at":"2099-01-06T00:00:00+00:00","scope":{"model":{"display_name":"Fable"}}}
|
|
1898
|
+
]}
|
|
1899
|
+
EOF2
|
|
1900
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-session-vs-fable.json" claude-accounts limits --force 2>&1)"
|
|
1901
|
+
check "session over threshold names the session bucket, not the Fable one" "LIMITED session at 100%" "$out"
|
|
1902
|
+
python3 - "$ACC/acct-01/.limited" <<'EOF2'
|
|
1903
|
+
import sys, time, calendar
|
|
1904
|
+
lines = open(sys.argv[1]).read().splitlines()
|
|
1905
|
+
epoch, detail = int(lines[0]), lines[1]
|
|
1906
|
+
want = calendar.timegm(time.strptime("2099-01-01T01:00:00", "%Y-%m-%dT%H:%M:%S"))
|
|
1907
|
+
assert epoch == want, (epoch, want, detail) # the SESSION reset
|
|
1908
|
+
assert "bucket=session percent=100" in detail, detail
|
|
1909
|
+
assert "resets_at=2099-01-01T01:00:00+00:00" in detail, detail
|
|
1910
|
+
EOF2
|
|
1911
|
+
[ $? -eq 0 ] && t_ok "marker epoch is the named bucket's own reset (session +1h, not Fable +5d)" \
|
|
1912
|
+
|| t_fail "marker epoch/bucket consistency" "$(cat "$ACC/acct-01/.limited" 2>&1 | tr '\n' ' ')"
|
|
1913
|
+
# Two ACCOUNT-level offenders: the longest-lived exclusion is the one named, with its
|
|
1914
|
+
# own reset — a session bucket that resets in an hour must not shorten a weekly park.
|
|
1915
|
+
cat > "$WORK/usage-session-vs-weekly.json" <<'EOF2'
|
|
1916
|
+
{"limits":[
|
|
1917
|
+
{"kind":"session","percent":100,"resets_at":"2099-01-01T01:00:00+00:00","scope":null},
|
|
1918
|
+
{"kind":"weekly_all","percent":95,"resets_at":"2099-01-05T00:00:00+00:00","scope":null}
|
|
1919
|
+
]}
|
|
1920
|
+
EOF2
|
|
1921
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-session-vs-weekly.json" claude-accounts limits --force 2>&1)"
|
|
1922
|
+
check "two account-level offenders: the longest-lived one is named" "LIMITED weekly_all at 95%" "$out"
|
|
1923
|
+
python3 - "$ACC/acct-01/.limited" <<'EOF2'
|
|
1924
|
+
import sys, time, calendar
|
|
1925
|
+
lines = open(sys.argv[1]).read().splitlines()
|
|
1926
|
+
want = calendar.timegm(time.strptime("2099-01-05T00:00:00", "%Y-%m-%dT%H:%M:%S"))
|
|
1927
|
+
assert int(lines[0]) == want, lines
|
|
1928
|
+
assert "bucket=weekly_all percent=95" in lines[1] and "resets_at=2099-01-05T00:00:00+00:00" in lines[1], lines
|
|
1929
|
+
EOF2
|
|
1930
|
+
[ $? -eq 0 ] && t_ok "weekly_all marker carries the weekly reset" \
|
|
1931
|
+
|| t_fail "weekly_all marker" "$(cat "$ACC/acct-01/.limited" 2>&1 | tr '\n' ' ')"
|
|
1932
|
+
# Only a model-scoped offender left: the scoped bucket is named (the shim treats it
|
|
1933
|
+
# as "switch model", never as an account park) with ITS reset.
|
|
1934
|
+
cat > "$WORK/usage-fable-only.json" <<'EOF2'
|
|
1935
|
+
{"limits":[
|
|
1936
|
+
{"kind":"session","percent":10,"resets_at":"2099-01-01T01:00:00+00:00","scope":null},
|
|
1937
|
+
{"kind":"weekly_scoped","percent":100,"resets_at":"2099-01-06T00:00:00+00:00","scope":{"model":{"display_name":"Fable"}}}
|
|
1938
|
+
]}
|
|
1939
|
+
EOF2
|
|
1940
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-fable-only.json" claude-accounts limits --force 2>&1)"
|
|
1941
|
+
check "a scoped-only offender still marks, scoped" "LIMITED weekly_scoped:Fable at 100%" "$out"
|
|
1942
|
+
grep -q "^bucket=weekly_scoped:Fable percent=100 " "$ACC/acct-01/.limited" 2>/dev/null \
|
|
1943
|
+
&& [ "$(head -1 "$ACC/acct-01/.limited")" = "$(python3 -c 'import time,calendar;print(calendar.timegm(time.strptime("2099-01-06T00:00:00","%Y-%m-%dT%H:%M:%S")))')" ] \
|
|
1944
|
+
&& t_ok "scoped marker carries the scoped bucket's reset" \
|
|
1945
|
+
|| t_fail "scoped marker" "$(cat "$ACC/acct-01/.limited" 2>&1 | tr '\n' ' ')"
|
|
1946
|
+
grep -q '"model": "Fable"' "$ACC/acct-01/limits.json" && t_ok "limits.json buckets record the model scope" \
|
|
1947
|
+
|| t_fail "bucket model field" "missing from limits.json"
|
|
1948
|
+
rm -f "$ACC/acct-01/limits.json" "$ACC/acct-01/.limited"
|
|
1949
|
+
|
|
1886
1950
|
# ---- 16a. future-proofing: works if the Fable bucket separation disappears ---------
|
|
1887
1951
|
cat > "$WORK/usage-no-fable.json" <<'EOF'
|
|
1888
1952
|
{"limits":[
|
|
@@ -61,7 +61,9 @@ class ResetHandler(BaseHTTPRequestHandler):
|
|
|
61
61
|
return
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
class
|
|
64
|
+
class CodexPoolSandbox:
|
|
65
|
+
"""A one-account codex pool with a fake usage/credits endpoint."""
|
|
66
|
+
|
|
65
67
|
def setUp(self) -> None:
|
|
66
68
|
self.temp = tempfile.TemporaryDirectory(prefix="multiacc-reset-")
|
|
67
69
|
self.pool = Path(self.temp.name) / "pool"
|
|
@@ -88,6 +90,8 @@ class CodexResetIntegrationTest(unittest.TestCase):
|
|
|
88
90
|
self.thread.join(timeout=5)
|
|
89
91
|
self.temp.cleanup()
|
|
90
92
|
|
|
93
|
+
|
|
94
|
+
class CodexResetIntegrationTest(CodexPoolSandbox, unittest.TestCase):
|
|
91
95
|
def run_limits(self) -> subprocess.CompletedProcess:
|
|
92
96
|
base = f"http://127.0.0.1:{self.server.server_port}/wham"
|
|
93
97
|
env = os.environ.copy()
|
|
@@ -166,5 +170,66 @@ class CodexResetIntegrationTest(unittest.TestCase):
|
|
|
166
170
|
self.assertEqual(self.server.state["credit_gets"], 1)
|
|
167
171
|
|
|
168
172
|
|
|
173
|
+
class CodexMarkerNamesOneBucketTest(CodexPoolSandbox, unittest.TestCase):
|
|
174
|
+
"""The marker's epoch is the reset of the bucket its detail line NAMES.
|
|
175
|
+
|
|
176
|
+
The codex writer used to pair the highest-PERCENT bucket's name with the
|
|
177
|
+
LATEST reset over every offender, so a five-hour window at 100% resetting
|
|
178
|
+
tonight was written under a seven-day epoch — and everything that trusts the
|
|
179
|
+
marker parked the account for a week over a window that refills in hours.
|
|
180
|
+
#20 fixed this for the claude pool and left codex behind.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def run_limits(self) -> subprocess.CompletedProcess: # no auto-reset: it clears markers
|
|
184
|
+
base = f"http://127.0.0.1:{self.server.server_port}/wham"
|
|
185
|
+
env = os.environ.copy()
|
|
186
|
+
env.update({"CODEX_ACCOUNTS_ROOT": str(self.pool),
|
|
187
|
+
"CODEX_MULTIACC_NO_SYNC": "1", "CODEX_MULTIACC_MIN_FETCH": "0",
|
|
188
|
+
"CODEX_MULTIACC_AUTO_RESET": "0", "PYTHONDONTWRITEBYTECODE": "1",
|
|
189
|
+
"CODEX_MULTIACC_USAGE_URL": f"{base}/usage"})
|
|
190
|
+
return subprocess.run([REPO / "bin/codex-accounts", "limits", "--force"],
|
|
191
|
+
capture_output=True, text=True, env=env, timeout=20, check=False)
|
|
192
|
+
|
|
193
|
+
def _marker(self):
|
|
194
|
+
text = (self.pool / "acct-01/.limited").read_text(encoding="utf-8").splitlines()
|
|
195
|
+
return int(text[0]), text[1]
|
|
196
|
+
|
|
197
|
+
def test_two_offenders_write_one_bucket_with_its_own_reset(self) -> None:
|
|
198
|
+
now = int(time.time())
|
|
199
|
+
session_reset, weekly_reset = now + 3600, now + 6 * 86400
|
|
200
|
+
self.server.state["usage"] = {"plan_type": "pro", "rate_limit": {
|
|
201
|
+
"allowed": True,
|
|
202
|
+
# 100% and back in an hour …
|
|
203
|
+
"primary_window": {"used_percent": 100, "limit_window_seconds": 18000,
|
|
204
|
+
"reset_at": session_reset},
|
|
205
|
+
# … beside 95% that is six days out. Both are over the threshold.
|
|
206
|
+
"secondary_window": {"used_percent": 95, "limit_window_seconds": 604800,
|
|
207
|
+
"reset_at": weekly_reset}}}
|
|
208
|
+
self.assertEqual(self.run_limits().returncode, 0)
|
|
209
|
+
epoch, detail = self._marker()
|
|
210
|
+
# The longest-lived offender is named, and the epoch is ITS reset — the
|
|
211
|
+
# account really is excluded until then, and a reader that takes the
|
|
212
|
+
# detail's percent gets the percent of the very bucket the epoch belongs
|
|
213
|
+
# to rather than a different bucket's.
|
|
214
|
+
self.assertEqual(epoch, weekly_reset)
|
|
215
|
+
self.assertIn("percent=95", detail)
|
|
216
|
+
self.assertIn(time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(weekly_reset)), detail)
|
|
217
|
+
self.assertNotIn("percent=100", detail)
|
|
218
|
+
|
|
219
|
+
def test_a_single_offender_still_names_itself(self) -> None:
|
|
220
|
+
now = int(time.time())
|
|
221
|
+
reset = now + 4 * 86400
|
|
222
|
+
self.server.state["usage"] = {"plan_type": "pro", "rate_limit": {
|
|
223
|
+
"allowed": True,
|
|
224
|
+
"primary_window": {"used_percent": 91, "limit_window_seconds": 604800,
|
|
225
|
+
"reset_at": reset},
|
|
226
|
+
"secondary_window": {"used_percent": 12, "limit_window_seconds": 18000,
|
|
227
|
+
"reset_at": now + 900}}}
|
|
228
|
+
self.assertEqual(self.run_limits().returncode, 0)
|
|
229
|
+
epoch, detail = self._marker()
|
|
230
|
+
self.assertEqual(epoch, reset)
|
|
231
|
+
self.assertIn("percent=91", detail)
|
|
232
|
+
|
|
233
|
+
|
|
169
234
|
if __name__ == "__main__":
|
|
170
235
|
unittest.main()
|