claude-multiacc 2.0.1 → 2.0.3

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
@@ -16,7 +16,7 @@ completely independent — separate manifests, credentials, telemetry, and logs
16
16
  either provider can be used, re-authenticated, or emptied without touching the other.
17
17
 
18
18
  Tested on: macOS (bash 3.2, zsh, Claude Code 2.1.207+, Codex CLI 0.147) and Ubuntu 24.04
19
- (bash 5.2). The compatibility suite currently covers 644 sandboxed cases with no
19
+ (bash 5.2). The compatibility suite currently covers 645 sandboxed cases with no
20
20
  network/quota use; the unified selector has its own adversarial contract suite.
21
21
 
22
22
  ## Unified selector for app-robot
@@ -39,8 +39,9 @@ database locks, retries, and launching the selected CLI.
39
39
  Successful responses also carry `eligible_count` and `eligible_alternative_count`,
40
40
  so callers can persist proof that sole-account reviewer fallback was unavoidable.
41
41
 
42
- Run `multiacc-select --version` for the selector protocol version. The npm package
43
- version is independent and remains available through `claude-multiacc --version`.
42
+ Run `multiacc-select --version` for the selector protocol version (currently
43
+ `2.0.1`). The npm package version is independent and remains available through
44
+ `claude-multiacc --version`.
44
45
  See [the complete selector contract](docs/UNIFIED_SELECTOR.md) for the request fields,
45
46
  ranking rules, stable errors, and caller boundary.
46
47
 
@@ -295,6 +296,12 @@ Codex-specific notes:
295
296
  own public client id) and the rotated credential is persisted 0600. Overrides for
296
297
  tests: `CODEX_MULTIACC_TOKEN_URL`, `CODEX_MULTIACC_CLIENT_ID`,
297
298
  `CODEX_MULTIACC_USAGE_URL`.
299
+ - **Earned usage resets are redeemed automatically at 95% used** (5% remaining) or
300
+ when the backend reports the limit finished. The limits refresher checks the reset
301
+ credits belonging to that same account, uses the soonest-expiring available credit,
302
+ and writes a per-account/window idempotency key before redemption so a lost response
303
+ or simultaneous fleet poll cannot spend a second reset. Set
304
+ `CODEX_MULTIACC_AUTO_RESET=0` only for emergency rollback/testing.
298
305
  - **API-key logins are rejected** — ChatGPT subscription accounts only, matching the
299
306
  addon's no-API-keys rule.
300
307
 
@@ -83,6 +83,8 @@ USAGE
83
83
  `codex exec` per account
84
84
  codex-accounts limits [--quiet] [--force] [--json]
85
85
  refresh usage windows from the ChatGPT usage endpoint, apply >=90% markers.
86
+ When a fresh bucket is at least 95% used, automatically redeems an earned
87
+ usage-limit reset if the account has one, using an idempotent request.
86
88
  Auto-refreshes long-expired access tokens via the OAuth refresh-token grant
87
89
  (rotated credential is persisted), so idle accounts keep fresh telemetry and
88
90
  stay selectable. Skips accounts fetched in the last 45s and honors
@@ -102,6 +104,7 @@ ENV
102
104
  CODEX_ACCOUNT pin the shim to one account
103
105
  CODEX_SHIM_RETRY=0 disable the `codex exec` auto-retry
104
106
  CODEX_MULTIACC_DISABLE=1 bypass the shim entirely
107
+ CODEX_MULTIACC_AUTO_RESET=0 disable automatic usage-reset redemption
105
108
  EOF
106
109
  }
107
110
 
@@ -1051,10 +1054,13 @@ cmd_limits() {
1051
1054
  case "$threshold" in ''|*[!0-9]*) threshold=90 ;; esac
1052
1055
  [ "$threshold" -gt 90 ] && threshold=90
1053
1056
  [ "$threshold" -lt 1 ] && threshold=90
1054
- "$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
1057
+ "$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" "$LIB_DIR" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
1055
1058
  import base64, datetime, json, os, sys, time, urllib.request
1056
1059
 
1057
- root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
1060
+ root, threshold, quiet, url, force, lib_dir = (sys.argv[1], int(sys.argv[2]),
1061
+ sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1', sys.argv[6])
1062
+ sys.path.insert(0, lib_dir)
1063
+ from codex_reset import try_auto_redeem
1058
1064
  now = time.time()
1059
1065
  # Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
1060
1066
  MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '240'))
@@ -1461,9 +1467,20 @@ for acct in manifest.get('accounts', []):
1461
1467
  session = [b['percent'] for b in buckets if b['group'] == 'session']
1462
1468
  weeklyp = max(weekly) if weekly else maxp
1463
1469
  sessionp = max(session) if session else 0
1470
+ try:
1471
+ reset_result = try_auto_redeem(d, aid, maxp, data, url, headers, say, int(now))
1472
+ except Exception as e:
1473
+ say(f'{aid}: usage reset automation failed unexpectedly ({type(e).__name__}); failing open')
1474
+ reset_result = {'status': 'error'}
1464
1475
  out = {'fetched_at': int(now), 'source': 'chatgpt', 'max_percent': maxp,
1465
1476
  'weekly_percent': weeklyp, 'session_percent': sessionp,
1466
1477
  'plan': str(data.get('plan_type') or ''), 'buckets': buckets}
1478
+ if reset_result.get('status') == 'redeemed':
1479
+ # The response proves the reset succeeded, but the usage GET happened before
1480
+ # it. Make that snapshot stale immediately so it cannot re-exclude the newly
1481
+ # refreshed account; the next scheduled pass replaces it with server truth.
1482
+ out['fetched_at'] = 0
1483
+ out['auto_reset'] = reset_result
1467
1484
  tmp = lpath + '.tmp'
1468
1485
  with open(tmp, 'w') as f:
1469
1486
  json.dump(out, f, indent=1)
@@ -1471,7 +1488,8 @@ for acct in manifest.get('accounts', []):
1471
1488
  # The fetch went through with this account's own bearer => its auth is alive.
1472
1489
  if clear_expired(d):
1473
1490
  say(f'{aid}: dead-auth marker cleared (authenticated successfully)')
1474
- offenders = [b for b in buckets if b['percent'] >= threshold]
1491
+ offenders = [] if reset_result.get('status') == 'redeemed' else [
1492
+ b for b in buckets if b['percent'] >= threshold]
1475
1493
  mpath = os.path.join(d, '.limited')
1476
1494
  if offenders:
1477
1495
  worst = max(offenders, key=lambda b: b['percent'])
@@ -1498,7 +1516,8 @@ for acct in manifest.get('accounts', []):
1498
1516
  try:
1499
1517
  txt = open(mpath).read()
1500
1518
  first = txt.splitlines()[0] if txt else ''
1501
- if ('reason=error-cooldown' in txt or 'reason=client-rate-limit' in txt) \
1519
+ if reset_result.get('status') != 'redeemed' \
1520
+ and ('reason=error-cooldown' in txt or 'reason=client-rate-limit' in txt) \
1502
1521
  and first.isdigit() and int(first) > now:
1503
1522
  keep = True
1504
1523
  except Exception:
@@ -342,7 +342,7 @@ The codex shim honors the same switches spelled `CODEX_*`: `CODEX_ACCOUNT`,
342
342
  ## Verification
343
343
 
344
344
  ```bash
345
- tests/run-tests.sh # 644 sandboxed compatibility tests, no quota
345
+ tests/run-tests.sh # 645 sandboxed compatibility tests, no quota
346
346
  claude-accounts verify # real matrix: `claude -p "reply OK"` per authed account
347
347
  claude-accounts verify --quick# auth presence/expiry only, no inference
348
348
  claude-accounts limits # live per-bucket usage incl. the Fable bucket
@@ -0,0 +1,170 @@
1
+ """Automatic redemption of earned Codex usage-limit reset credits.
2
+
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
6
+ before the mutation so a lost response can be retried without spending twice.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ import uuid
18
+
19
+ RESET_AT_USED_PERCENT = 95
20
+ REDEEM_COOLDOWN_SECONDS = 900
21
+ STATE_FILE = ".usage-reset.json"
22
+
23
+
24
+ def auto_reset_enabled() -> bool:
25
+ """Return whether automatic redemption is enabled (on by default)."""
26
+ return os.environ.get("CODEX_MULTIACC_AUTO_RESET", "1").strip().lower() \
27
+ not in {"0", "false", "no", "off"}
28
+
29
+
30
+ def _load(path: str) -> dict:
31
+ try:
32
+ value = json.load(open(path, encoding="utf-8"))
33
+ return value if isinstance(value, dict) else {}
34
+ except Exception:
35
+ return {}
36
+
37
+
38
+ def _write(path: str, value: dict) -> None:
39
+ temp = f"{path}.tmp.{os.getpid()}"
40
+ descriptor = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
41
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
42
+ json.dump(value, handle, indent=2, sort_keys=True)
43
+ handle.write("\n")
44
+ os.replace(temp, path)
45
+
46
+
47
+ def _reset_urls(usage_url: str) -> tuple[str, str]:
48
+ explicit_list = os.environ.get("CODEX_MULTIACC_RESET_CREDITS_URL", "").strip()
49
+ explicit_consume = os.environ.get("CODEX_MULTIACC_RESET_CONSUME_URL", "").strip()
50
+ if explicit_list and explicit_consume:
51
+ return explicit_list, explicit_consume
52
+ parsed = urllib.parse.urlsplit(usage_url)
53
+ path = parsed.path
54
+ if path.endswith("/wham/usage"):
55
+ prefix = path[: -len("/wham/usage")] + "/wham"
56
+ elif path.endswith("/api/codex/usage"):
57
+ prefix = path[: -len("/usage")]
58
+ elif "/backend-api/" in path:
59
+ prefix = path.split("/backend-api/", 1)[0] + "/backend-api/wham"
60
+ else:
61
+ prefix = path.rsplit("/usage", 1)[0]
62
+ base = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, prefix, "", ""))
63
+ return (explicit_list or f"{base}/rate-limit-reset-credits",
64
+ explicit_consume or f"{base}/rate-limit-reset-credits/consume")
65
+
66
+
67
+ def _request(url: str, headers: dict, payload: dict | None = None) -> dict:
68
+ data = json.dumps(payload).encode() if payload is not None else None
69
+ request = urllib.request.Request(url, data=data, headers=headers,
70
+ method="POST" if data is not None else "GET")
71
+ response = urllib.request.urlopen(request, timeout=15)
72
+ value = json.loads(response.read().decode())
73
+ if not isinstance(value, dict):
74
+ raise ValueError("reset endpoint returned a non-object")
75
+ return value
76
+
77
+
78
+ def _credit_to_redeem(details: dict) -> tuple[int, str | None]:
79
+ try:
80
+ available_count = max(0, int(details.get("available_count") or 0))
81
+ except (TypeError, ValueError):
82
+ available_count = 0
83
+ available = [item for item in details.get("credits", [])
84
+ if isinstance(item, dict) and item.get("status") == "available"]
85
+ available.sort(key=lambda item: item.get("expires_at") or "9999-12-31T23:59:59Z")
86
+ credit_id = str(available[0].get("id") or "") if available else ""
87
+ return available_count, credit_id or None
88
+
89
+
90
+ def _pending_request(account_dir: str, now: int) -> tuple[dict, str]:
91
+ state_path = os.path.join(account_dir, STATE_FILE)
92
+ state = _load(state_path)
93
+ if state.get("state") == "complete" and int(state.get("suppress_until") or 0) > now:
94
+ return state, "cooldown"
95
+ if state.get("state") == "pending" and state.get("redeem_request_id"):
96
+ return state, "pending"
97
+ return {}, "new"
98
+
99
+
100
+ def _redeem_request_id(account_id: str, usage: dict, headers: dict, now: int) -> str:
101
+ """Return one fleet-stable UUID for this account's current limit windows."""
102
+ 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}"]
118
+ seed = json.dumps({"account": subject, "resets": cycle}, sort_keys=True)
119
+ return str(uuid.uuid5(uuid.NAMESPACE_URL, seed))
120
+
121
+
122
+ def try_auto_redeem(account_dir: str, account_id: str, used_percent: int,
123
+ usage: dict, usage_url: str, headers: dict, say,
124
+ now: int | None = None) -> dict:
125
+ """Redeem one available reset and return a non-secret outcome document."""
126
+ current = int(now if now is not None else time.time())
127
+ if not auto_reset_enabled() or used_percent < RESET_AT_USED_PERCENT:
128
+ return {"status": "not_eligible"}
129
+ state, disposition = _pending_request(account_dir, current)
130
+ if disposition == "cooldown":
131
+ return {"status": "cooldown", "outcome": state.get("outcome")}
132
+ list_url, consume_url = _reset_urls(usage_url)
133
+ 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"}
137
+ try:
138
+ available_count, credit_id = _credit_to_redeem(_request(list_url, headers))
139
+ except Exception as error:
140
+ say(f"{account_id}: usage reset availability failed ({type(error).__name__}); failing open")
141
+ return {"status": "error"}
142
+ if available_count <= 0:
143
+ return {"status": "no_credit"}
144
+ request_id = _redeem_request_id(account_id, usage, headers, current)
145
+ state = {"schema": 1, "state": "pending", "redeem_request_id": request_id,
146
+ "credit_id": credit_id, "started_at": current}
147
+ _write(os.path.join(account_dir, STATE_FILE), state)
148
+ payload = {"redeem_request_id": state["redeem_request_id"]}
149
+ if state.get("credit_id"):
150
+ payload["credit_id"] = state["credit_id"]
151
+ try:
152
+ response = _request(consume_url, headers, payload)
153
+ except Exception as error:
154
+ say(f"{account_id}: usage reset redeem failed ({type(error).__name__}); retry is idempotent")
155
+ return {"status": "pending"}
156
+ outcome = str(response.get("code") or "unknown")
157
+ if outcome in {"reset", "already_redeemed"}:
158
+ complete = {"schema": 1, "state": "complete", "outcome": outcome,
159
+ "redeem_request_id": state["redeem_request_id"],
160
+ "redeemed_at": current, "suppress_until": current + REDEEM_COOLDOWN_SECONDS,
161
+ "windows_reset": int(response.get("windows_reset") or 0)}
162
+ _write(os.path.join(account_dir, STATE_FILE), complete)
163
+ say(f"{account_id}: usage reset redeemed automatically at {used_percent}% used")
164
+ return {"status": "redeemed", "outcome": outcome,
165
+ "windows_reset": complete["windows_reset"], "redeemed_at": current}
166
+ try:
167
+ os.remove(os.path.join(account_dir, STATE_FILE))
168
+ except OSError:
169
+ pass
170
+ return {"status": outcome}
@@ -11,7 +11,7 @@ from datetime import datetime, timezone
11
11
  from decimal import Decimal, InvalidOperation
12
12
 
13
13
  SCHEMA = "claude-multiacc/pool-selection.v2"
14
- SELECTOR_VERSION = "2.0.0"
14
+ SELECTOR_VERSION = "2.0.1"
15
15
  TIME_RE = re.compile(
16
16
  r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-5][0-9]"
17
17
  r"(\.[0-9]{1,6})?(Z|[+-][0-9]{2}:[0-9]{2})$")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2399,6 +2399,7 @@ unset CODEX_HOME CODEX_ACCOUNT CODEX_SHIM_ACTIVE 2>/dev/null || true
2399
2399
  export CODEX_MULTIACC_NO_SYNC=1
2400
2400
  export CODEX_MULTIACC_MIN_FETCH=0
2401
2401
  export CODEX_MULTIACC_CLIENT_SCAN_TTL=0
2402
+ export CODEX_MULTIACC_AUTO_RESET=0
2402
2403
  export CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-endpoint-missing.json"
2403
2404
  # Default usage URL is an offline missing fixture: the SHIM's opportunistic
2404
2405
  # background `limits --quiet` kick must never reach a real endpoint from tests
@@ -4071,6 +4072,14 @@ for _bin in claude-accounts codex-accounts; do
4071
4072
  || t_fail "$_bin unknown verb --help" "exited 0 — the probe would accept a verb that does not exist"
4072
4073
  done
4073
4074
 
4075
+ # The reset-credit contract is easier to prove against a stateful local HTTP server
4076
+ # than file:// fixtures: it pins thresholding, credit ordering and idempotent POST retry.
4077
+ if CODEX_MULTIACC_AUTO_RESET=1 python3 "$REPO_DIR/tests/test_codex_reset.py"; then
4078
+ t_ok "codex: automatic earned-reset integration suite"
4079
+ else
4080
+ t_fail "codex automatic reset suite" "see unittest output above"
4081
+ fi
4082
+
4074
4083
  # ---- summary ---------------------------------------------------------------------
4075
4084
  echo
4076
4085
  echo "passed: $PASS failed: $FAIL"
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env python3
2
+ """Sandboxed integration tests for automatic Codex usage-reset redemption."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import subprocess
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ import unittest
16
+ import uuid
17
+
18
+ REPO = Path(__file__).resolve().parents[1]
19
+
20
+
21
+ def jwt(claims: dict) -> str:
22
+ payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=")
23
+ return f"header.{payload}.signature"
24
+
25
+
26
+ class ResetHandler(BaseHTTPRequestHandler):
27
+ def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract
28
+ state = self.server.state
29
+ if self.path == "/wham/usage":
30
+ state["usage_gets"] += 1
31
+ body = state["usage"]
32
+ elif self.path == "/wham/rate-limit-reset-credits":
33
+ state["credit_gets"] += 1
34
+ body = state["credits"]
35
+ else:
36
+ self.send_error(404)
37
+ return
38
+ encoded = json.dumps(body).encode()
39
+ self.send_response(200)
40
+ self.send_header("Content-Type", "application/json")
41
+ self.send_header("Content-Length", str(len(encoded)))
42
+ self.end_headers()
43
+ self.wfile.write(encoded)
44
+
45
+ def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract
46
+ state = self.server.state
47
+ if self.path != "/wham/rate-limit-reset-credits/consume":
48
+ self.send_error(404)
49
+ return
50
+ length = int(self.headers.get("Content-Length") or 0)
51
+ state["posts"].append(json.loads(self.rfile.read(length)))
52
+ status, body = state["responses"].pop(0)
53
+ encoded = json.dumps(body).encode()
54
+ self.send_response(status)
55
+ self.send_header("Content-Type", "application/json")
56
+ self.send_header("Content-Length", str(len(encoded)))
57
+ self.end_headers()
58
+ self.wfile.write(encoded)
59
+
60
+ def log_message(self, _format: str, *_args) -> None:
61
+ return
62
+
63
+
64
+ class CodexResetIntegrationTest(unittest.TestCase):
65
+ def setUp(self) -> None:
66
+ self.temp = tempfile.TemporaryDirectory(prefix="multiacc-reset-")
67
+ self.pool = Path(self.temp.name) / "pool"
68
+ account = self.pool / "acct-01"
69
+ account.mkdir(parents=True)
70
+ manifest = {"version": 1, "threshold": 90, "accounts": [
71
+ {"id": "acct-01", "email": "test@example.invalid", "home": "mac"}]}
72
+ (self.pool / "accounts.json").write_text(json.dumps(manifest), encoding="utf-8")
73
+ claims = {"exp": int(time.time()) + 86400,
74
+ "https://api.openai.com/auth": {"chatgpt_account_id": "account-test"}}
75
+ auth = {"auth_mode": "chatgpt", "tokens": {
76
+ "access_token": jwt(claims), "refresh_token": "fake-refresh",
77
+ "account_id": "account-test"}}
78
+ (account / "auth.json").write_text(json.dumps(auth), encoding="utf-8")
79
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), ResetHandler)
80
+ self.server.state = {"usage_gets": 0, "credit_gets": 0, "posts": [],
81
+ "usage": {}, "credits": {}, "responses": []}
82
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
83
+ self.thread.start()
84
+
85
+ def tearDown(self) -> None:
86
+ self.server.shutdown()
87
+ self.server.server_close()
88
+ self.thread.join(timeout=5)
89
+ self.temp.cleanup()
90
+
91
+ def run_limits(self) -> subprocess.CompletedProcess:
92
+ base = f"http://127.0.0.1:{self.server.server_port}/wham"
93
+ env = os.environ.copy()
94
+ env.update({"CODEX_ACCOUNTS_ROOT": str(self.pool),
95
+ "CODEX_MULTIACC_NO_SYNC": "1", "CODEX_MULTIACC_MIN_FETCH": "0",
96
+ "CODEX_MULTIACC_AUTO_RESET": "1", "PYTHONDONTWRITEBYTECODE": "1",
97
+ "CODEX_MULTIACC_USAGE_URL": f"{base}/usage"})
98
+ env.pop("CODEX_MULTIACC_RESET_CREDITS_URL", None)
99
+ env.pop("CODEX_MULTIACC_RESET_CONSUME_URL", None)
100
+ return subprocess.run([REPO / "bin/codex-accounts", "limits", "--force"],
101
+ capture_output=True, text=True, env=env, timeout=20, check=False)
102
+
103
+ def test_redeems_at_five_percent_remaining_once(self) -> None:
104
+ now = int(time.time())
105
+ self.server.state["usage"] = {"plan_type": "pro", "rate_limit": {
106
+ "allowed": True, "primary_window": {"used_percent": 95,
107
+ "limit_window_seconds": 18000, "reset_at": now + 3600}},
108
+ "rate_limit_reset_credits": {"available_count": 2}}
109
+ self.server.state["credits"] = {"available_count": 2, "credits": [
110
+ {"id": "later", "status": "available", "expires_at": "2026-09-02T00:00:00Z"},
111
+ {"id": "sooner", "status": "available", "expires_at": "2026-09-01T00:00:00Z"}]}
112
+ self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 2})]
113
+ result = self.run_limits()
114
+ self.assertEqual(result.returncode, 0, result.stderr)
115
+ self.assertIn("usage reset redeemed automatically at 95% used", result.stdout)
116
+ self.assertEqual(len(self.server.state["posts"]), 1)
117
+ request = self.server.state["posts"][0]
118
+ self.assertEqual(request["credit_id"], "sooner")
119
+ uuid.UUID(request["redeem_request_id"])
120
+ state = json.loads((self.pool / "acct-01/.usage-reset.json").read_text())
121
+ self.assertEqual(state["state"], "complete")
122
+ marker = self.pool / "acct-01/.limited"
123
+ self.assertFalse(marker.exists())
124
+ marker.write_text(f"{now + 3600}\nreason=client-rate-limit\n", encoding="utf-8")
125
+ (self.pool / "acct-01/.usage-reset.json").unlink()
126
+ self.server.state["responses"] = [(200, {"code": "already_redeemed"})]
127
+ result = self.run_limits()
128
+ self.assertEqual(result.returncode, 0, result.stderr)
129
+ self.assertFalse(marker.exists())
130
+ self.assertEqual(self.server.state["posts"][0]["redeem_request_id"],
131
+ self.server.state["posts"][1]["redeem_request_id"])
132
+ self.run_limits()
133
+ self.assertEqual(len(self.server.state["posts"]), 2)
134
+
135
+ def test_does_not_redeem_above_five_percent_remaining(self) -> None:
136
+ now = int(time.time())
137
+ self.server.state["usage"] = {"rate_limit": {"allowed": True,
138
+ "primary_window": {"used_percent": 94, "limit_window_seconds": 18000,
139
+ "reset_at": now + 3600}},
140
+ "rate_limit_reset_credits": {"available_count": 1}}
141
+ result = self.run_limits()
142
+ self.assertEqual(result.returncode, 0, result.stderr)
143
+ self.assertEqual(self.server.state["credit_gets"], 0)
144
+ self.assertEqual(self.server.state["posts"], [])
145
+ self.assertFalse((self.pool / "acct-01/.usage-reset.json").exists())
146
+
147
+ def test_retries_a_lost_response_with_the_same_idempotency_key(self) -> None:
148
+ now = int(time.time())
149
+ self.server.state["usage"] = {"rate_limit": {"allowed": False,
150
+ "limit_reached": True, "primary_window": {"used_percent": 100,
151
+ "limit_window_seconds": 604800, "reset_at": now + 86400}},
152
+ "rate_limit_reset_credits": {"available_count": 1}}
153
+ self.server.state["credits"] = {"available_count": 1, "credits": []}
154
+ self.server.state["responses"] = [
155
+ (500, {"error": "response lost"}),
156
+ (200, {"code": "already_redeemed", "windows_reset": 2})]
157
+ first = self.run_limits()
158
+ self.assertIn("retry is idempotent", first.stdout)
159
+ self.assertEqual(json.loads((self.pool / "acct-01/.usage-reset.json").read_text())
160
+ ["state"], "pending")
161
+ second = self.run_limits()
162
+ self.assertEqual(second.returncode, 0, second.stderr)
163
+ self.assertEqual(len(self.server.state["posts"]), 2)
164
+ keys = [request["redeem_request_id"] for request in self.server.state["posts"]]
165
+ self.assertEqual(keys[0], keys[1])
166
+ self.assertEqual(self.server.state["credit_gets"], 1)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ unittest.main()
@@ -54,7 +54,7 @@ class SelectorTests(unittest.TestCase):
54
54
  "e8ef0b4bc7b95e76231ee6da79616d92bd9251c1c991845c8c0524fcbfb6593a")
55
55
  self.assertEqual(
56
56
  response["selection_digest"],
57
- "66179083753e967bbe3914692103c1b4a30966a86ecf85cd62e14f3511c78cc3")
57
+ "2c9607e3dc1fde3ddf4e4469fbe48cf0e6a3c8114ef0c6b5f3c82f8ed4f78e3e")
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
- "eaea620082bb0882a12598b8cc46e79090da07be0b307f34f0a68f555a6c9d3e")
71
+ "2f018ab0e308a8465898e75a867e18d8620c03b4a49109edc1d2d590bf0d1121")
72
72
 
73
73
  def test_both_chooses_each_provider_and_survives_provider_loss(self):
74
74
  codex = select(_request([
@@ -155,7 +155,7 @@ class SelectorTests(unittest.TestCase):
155
155
  self.assertEqual(overflow_seen["error_code"], "no_candidate")
156
156
  self.assertEqual(overflow_history["error_detail"], {
157
157
  "field": "candidates[0].reservation_history.active_expires_at"})
158
- self.assertEqual(invalid, {"schema": SCHEMA, "selector_version": "2.0.0", "ok": False,
158
+ self.assertEqual(invalid, {"schema": SCHEMA, "selector_version": "2.0.1", "ok": False,
159
159
  "error_code": "invalid_request", "error_detail": {"field": "$"}})
160
160
 
161
161
  def test_rfc8785_digest_vectors(self):
@@ -206,7 +206,7 @@ class SelectorTests(unittest.TestCase):
206
206
  self.assertTrue(all(json.loads(item.stdout)["error_code"] == "invalid_request"
207
207
  for item in bad))
208
208
  self.assertEqual(json.loads(decimal_response.stdout)["account_id"], "z-lower")
209
- self.assertEqual(version, "2.0.0")
209
+ self.assertEqual(version, "2.0.1")
210
210
  self.assertEqual(versions, ["2.0.0", "2.0.1"])
211
211
 
212
212