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.
@@ -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
- worst = max(offenders, key=lambda b: b['percent'])
2179
- reset_epoch = max(int(b['resets_epoch']) for b in offenders)
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')
@@ -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
- worst = max(offenders, key=lambda b: b['percent'])
1496
- reset_epoch = max(int(b['resets_epoch']) for b in offenders)
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')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.17",
3
+ "version": "2.0.19",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 CodexResetIntegrationTest(unittest.TestCase):
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()