claude-multiacc 2.0.22 → 2.0.24

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 CHANGED
@@ -378,6 +378,12 @@ Codex-specific notes:
378
378
  and writes a per-account/window idempotency key before redemption so a lost response
379
379
  or simultaneous fleet poll cannot spend a second reset. Set
380
380
  `CODEX_MULTIACC_AUTO_RESET=0` only for emergency rollback/testing.
381
+ A finished-limit verdict takes precedence over a conflicting percentage. The
382
+ redemption key excludes unused model windows whose reset clocks move between reads.
383
+ Eligible accounts, including spent ones, check the credit endpoint every five
384
+ minutes even if the usage summary still says zero credits. A newly granted reset
385
+ is redeemed during that pass and clears the old limit; no-credit results are
386
+ checked again next pass, without waiting for the weekly reset date.
381
387
  - **API-key logins are rejected** — ChatGPT subscription accounts only, matching the
382
388
  addon's no-API-keys rule.
383
389
 
@@ -1,8 +1,9 @@
1
1
  """Automatic redemption of earned Codex usage-limit reset credits.
2
2
 
3
3
  The Codex CLI exposes reset credits through the same authenticated backend as
4
- usage telemetry. This module is called only after a fresh usage response says
5
- an account has 5% or less remaining. A pending idempotency key is written
4
+ usage telemetry. Each scheduled eligible usage pass reads the credit endpoint,
5
+ even when the usage response's embedded credit summary is empty or out of date.
6
+ A pending idempotency key is written
6
7
  before the mutation so a lost response can be retried without spending twice.
7
8
  """
8
9
 
@@ -16,6 +17,8 @@ import urllib.parse
16
17
  import urllib.request
17
18
  import uuid
18
19
 
20
+ from codex_reset_windows import reset_windows
21
+
19
22
  RESET_AT_USED_PERCENT = 95
20
23
  REDEEM_COOLDOWN_SECONDS = 900
21
24
  STATE_FILE = ".usage-reset.json"
@@ -100,21 +103,8 @@ def _pending_request(account_dir: str, now: int) -> tuple[dict, str]:
100
103
  def _redeem_request_id(account_id: str, usage: dict, headers: dict, now: int) -> str:
101
104
  """Return one fleet-stable UUID for this account's current limit windows."""
102
105
  subject = str(headers.get("chatgpt-account-id") or account_id)
103
- resets = []
104
- stack = [usage]
105
- while stack:
106
- node = stack.pop()
107
- if isinstance(node, dict):
108
- value = node.get("reset_at")
109
- if value is not None:
110
- try:
111
- resets.append(int(float(value)))
112
- except (TypeError, ValueError):
113
- pass
114
- stack.extend(node.values())
115
- elif isinstance(node, list):
116
- stack.extend(node)
117
- cycle = sorted(set(resets)) or [f"hour-{now // 3600}"]
106
+ _, resets = reset_windows(usage, RESET_AT_USED_PERCENT)
107
+ cycle = resets or [f"hour-{now // 3600}"]
118
108
  seed = json.dumps({"account": subject, "resets": cycle}, sort_keys=True)
119
109
  return str(uuid.uuid5(uuid.NAMESPACE_URL, seed))
120
110
 
@@ -124,16 +114,16 @@ def try_auto_redeem(account_dir: str, account_id: str, used_percent: int,
124
114
  now: int | None = None) -> dict:
125
115
  """Redeem one available reset and return a non-secret outcome document."""
126
116
  current = int(now if now is not None else time.time())
127
- if not auto_reset_enabled() or used_percent < RESET_AT_USED_PERCENT:
117
+ finished, _ = reset_windows(usage, RESET_AT_USED_PERCENT)
118
+ if not auto_reset_enabled() or (used_percent < RESET_AT_USED_PERCENT and not finished):
128
119
  return {"status": "not_eligible"}
129
120
  state, disposition = _pending_request(account_dir, current)
130
121
  if disposition == "cooldown":
131
122
  return {"status": "cooldown", "outcome": state.get("outcome")}
132
123
  list_url, consume_url = _reset_urls(usage_url)
133
124
  if disposition == "new":
134
- summary = usage.get("rate_limit_reset_credits")
135
- if isinstance(summary, dict) and int(summary.get("available_count") or 0) <= 0:
136
- return {"status": "no_credit"}
125
+ # Newly granted credits can precede the usage response's embedded summary.
126
+ # The scheduled usage cadence bounds these reads, including spent accounts.
137
127
  try:
138
128
  available_count, credit_id = _credit_to_redeem(_request(list_url, headers))
139
129
  except Exception as error:
@@ -0,0 +1,36 @@
1
+ """Redemption eligibility and identity use exhausted windows, never idle model clocks."""
2
+
3
+
4
+ def _number(value):
5
+ try:
6
+ return float(value)
7
+ except (TypeError, ValueError, OverflowError):
8
+ return 0
9
+
10
+
11
+ def reset_windows(usage: dict, threshold: int) -> tuple[bool, list[int]]:
12
+ finished, resets, stack = False, set(), [usage]
13
+ while stack:
14
+ node = stack.pop()
15
+ if isinstance(node, list):
16
+ stack.extend(node)
17
+ continue
18
+ if not isinstance(node, dict):
19
+ continue
20
+ hard_limit = node.get("limit_reached") is True or node.get("allowed") is False
21
+ finished = finished or hard_limit
22
+ windows = [node] if _number(node.get("used_percent")) >= threshold else []
23
+ if hard_limit:
24
+ reported = [node[key] for key in ("primary_window", "secondary_window")
25
+ if isinstance(node.get(key), dict)]
26
+ peak = max((_number(window.get("used_percent")) for window in reported), default=0)
27
+ windows.extend(window for window in reported if _number(window.get("used_percent")) == peak)
28
+ for window in windows:
29
+ try:
30
+ reset = int(float(window.get("reset_at")))
31
+ if reset > 0:
32
+ resets.add(reset)
33
+ except (TypeError, ValueError, OverflowError):
34
+ pass
35
+ stack.extend(node.values())
36
+ return finished, sorted(resets)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.22",
3
+ "version": "2.0.24",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6558,7 +6558,7 @@ unset FAKE_KEYCHAIN_DIR
6558
6558
 
6559
6559
  # The reset-credit contract is easier to prove against a stateful local HTTP server
6560
6560
  # than file:// fixtures: it pins thresholding, credit ordering and idempotent POST retry.
6561
- if CODEX_MULTIACC_AUTO_RESET=1 python3 "$REPO_DIR/tests/test_codex_reset.py"; then
6561
+ if CODEX_MULTIACC_AUTO_RESET=1 python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_codex_reset*.py'; then
6562
6562
  t_ok "codex: automatic earned-reset integration suite"
6563
6563
  else
6564
6564
  t_fail "codex automatic reset suite" "see unittest output above"
@@ -0,0 +1,94 @@
1
+ """Scheduled passes discover newly granted resets despite an old usage summary."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import time
7
+ import unittest
8
+
9
+ import test_codex_reset as fixtures
10
+
11
+
12
+ class ResetPollingTest(fixtures.CodexPoolSandbox, unittest.TestCase):
13
+ def setUp(self):
14
+ super().setUp()
15
+ now = int(time.time())
16
+ self.server.state["usage"] = {"rate_limit": {"allowed": False, "limit_reached": True,
17
+ "primary_window": {"used_percent": 100, "limit_window_seconds": 604800,
18
+ "reset_at": now + 6 * 86400}},
19
+ "rate_limit_reset_credits": {"available_count": 0}}
20
+ self.server.state["credits"] = {"available_count": 0, "credits": []}
21
+
22
+ def run_scheduled(self, enabled="1"):
23
+ base = f"http://127.0.0.1:{self.server.server_port}/wham"
24
+ env = dict(os.environ, CODEX_ACCOUNTS_ROOT=str(self.pool), CODEX_MULTIACC_NO_SYNC="1",
25
+ CODEX_MULTIACC_AUTO_RESET=enabled, CODEX_MULTIACC_USAGE_URL=f"{base}/usage",
26
+ PYTHONDONTWRITEBYTECODE="1")
27
+ for key in ("CODEX_MULTIACC_MIN_FETCH", "CODEX_MULTIACC_RESET_CREDITS_URL",
28
+ "CODEX_MULTIACC_RESET_CONSUME_URL"):
29
+ env.pop(key, None)
30
+ result = subprocess.run([fixtures.REPO / "bin/codex-accounts", "limits", "--quiet"],
31
+ capture_output=True, text=True, env=env, timeout=20, check=False)
32
+ self.assertEqual(result.returncode, 0, result.stderr)
33
+
34
+ def next_pass(self):
35
+ path = self.pool / "acct-01/limits.json"
36
+ usage = json.loads(path.read_text())
37
+ usage["fetched_at"] = int(time.time()) - 301
38
+ path.write_text(json.dumps(usage))
39
+
40
+ def grant_credit(self):
41
+ self.server.state["credits"] = {"available_count": 1, "credits": [
42
+ {"id": "newly-granted", "status": "available", "expires_at": "2099-01-01T00:00:00Z"}]}
43
+ self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 1})]
44
+
45
+ def test_limited_account_polls_again_and_uses_a_new_credit(self):
46
+ self.run_scheduled()
47
+ self.assertEqual(self.server.state["credit_gets"], 1)
48
+ marker = self.pool / "acct-01/.limited"
49
+ self.assertTrue(marker.exists())
50
+ self.assertEqual(self.server.state["posts"], [])
51
+ self.run_scheduled()
52
+ self.assertEqual(self.server.state["credit_gets"], 1) # Normal fetch cadence still applies.
53
+ self.next_pass()
54
+ self.run_scheduled()
55
+ self.assertEqual(self.server.state["credit_gets"], 2)
56
+ self.assertTrue(marker.exists())
57
+ self.grant_credit()
58
+ self.next_pass()
59
+ self.run_scheduled()
60
+ self.assertEqual(self.server.state["credit_gets"], 3)
61
+ self.assertEqual(self.server.state["posts"][0]["credit_id"], "newly-granted")
62
+ self.assertFalse(marker.exists())
63
+ receipt = json.loads((self.pool / "acct-01/.usage-reset.json").read_text())
64
+ self.assertEqual(receipt["state"], "complete")
65
+ usage = json.loads((self.pool / "acct-01/limits.json").read_text())
66
+ self.assertEqual(usage["fetched_at"], 0) # Pre-reset exhaustion cannot exclude it again.
67
+
68
+ def test_malformed_usage_summary_does_not_hide_a_credit(self):
69
+ self.server.state["usage"]["rate_limit_reset_credits"]["available_count"] = "unknown"
70
+ self.grant_credit()
71
+ self.run_scheduled()
72
+ self.assertEqual(len(self.server.state["posts"]), 1)
73
+
74
+ def test_credit_lookup_failure_preserves_limit_and_retries_next_pass(self):
75
+ self.server.state["credits"] = [] # An unreadable credit response is not a reset.
76
+ self.run_scheduled()
77
+ self.assertTrue((self.pool / "acct-01/.limited").exists())
78
+ self.assertEqual(self.server.state["posts"], [])
79
+ self.grant_credit()
80
+ self.next_pass()
81
+ self.run_scheduled()
82
+ self.assertEqual(self.server.state["credit_gets"], 2)
83
+ self.assertFalse((self.pool / "acct-01/.limited").exists())
84
+
85
+ def test_disabled_automation_does_not_query_or_spend_credits(self):
86
+ self.grant_credit()
87
+ self.run_scheduled(enabled="0")
88
+ self.assertEqual(self.server.state["credit_gets"], 0)
89
+ self.assertEqual(self.server.state["posts"], [])
90
+ self.assertTrue((self.pool / "acct-01/.limited").exists())
91
+
92
+
93
+ if __name__ == "__main__":
94
+ unittest.main()
@@ -0,0 +1,43 @@
1
+ """Earned resets honor finished limits and ignore unrelated rolling windows."""
2
+ import time
3
+ import unittest
4
+
5
+ import test_codex_reset as fixtures
6
+
7
+
8
+ class ResetWindowsTest(fixtures.CodexPoolSandbox, unittest.TestCase):
9
+ run_limits = fixtures.CodexResetIntegrationTest.run_limits
10
+
11
+ def test_finished_limit_redeems_even_below_numeric_reset_threshold(self):
12
+ now = int(time.time())
13
+ self.server.state["usage"] = {"rate_limit": {"allowed": False, "limit_reached": True,
14
+ "primary_window": {"used_percent": 94, "limit_window_seconds": 604800, "reset_at": now + 86400}},
15
+ "rate_limit_reset_credits": {"available_count": 1}}
16
+ self.server.state["credits"] = {"available_count": 1, "credits": []}
17
+ self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 1})]
18
+ result = self.run_limits()
19
+ self.assertEqual(result.returncode, 0, result.stderr)
20
+ self.assertEqual(len(self.server.state["posts"]), 1)
21
+
22
+ def test_unused_model_windows_do_not_change_the_fleet_redemption_key(self):
23
+ now = int(time.time())
24
+ self.server.state["usage"] = {"rate_limit": {"allowed": True,
25
+ "primary_window": {"used_percent": 95, "limit_window_seconds": 604800, "reset_at": now + 86400}},
26
+ "additional_rate_limits": [{"limit_name": "Unused model", "rate_limit": {
27
+ "primary_window": {"used_percent": 0, "limit_window_seconds": 18000, "reset_at": now + 18000}}}],
28
+ "rate_limit_reset_credits": {"available_count": 2}}
29
+ self.server.state["credits"] = {"available_count": 2, "credits": []}
30
+ self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 1}),
31
+ (200, {"code": "already_redeemed", "windows_reset": 1})]
32
+ self.assertEqual(self.run_limits().returncode, 0)
33
+ # A fleet peer has no local receipt and sees a shifted idle-model window.
34
+ (self.pool / "acct-01/.usage-reset.json").unlink()
35
+ self.server.state["usage"]["additional_rate_limits"][0]["rate_limit"]["primary_window"]["reset_at"] += 30
36
+ self.assertEqual(self.run_limits().returncode, 0)
37
+ posts = self.server.state["posts"]
38
+ self.assertEqual(len(posts), 2)
39
+ self.assertEqual(posts[0]["redeem_request_id"], posts[1]["redeem_request_id"])
40
+
41
+
42
+ if __name__ == "__main__":
43
+ unittest.main()