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 +10 -3
- package/bin/codex-accounts +23 -4
- package/docs/ACCOUNT_OPERATIONS.md +1 -1
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/codex_reset.py +170 -0
- package/lib/selector_primitives.py +1 -1
- package/package.json +1 -1
- package/tests/run-tests.sh +9 -0
- package/tests/test_codex_reset.py +170 -0
- package/tests/test_selector.py +4 -4
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
|
|
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
|
|
43
|
-
version is independent and remains available through
|
|
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
|
|
package/bin/codex-accounts
CHANGED
|
@@ -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]),
|
|
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 = [
|
|
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 ('
|
|
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 #
|
|
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
|
|
Binary file
|
|
Binary file
|
|
@@ -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.
|
|
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
package/tests/run-tests.sh
CHANGED
|
@@ -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()
|
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
|
+
"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
|
-
"
|
|
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.
|
|
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.
|
|
209
|
+
self.assertEqual(version, "2.0.1")
|
|
210
210
|
self.assertEqual(versions, ["2.0.0", "2.0.1"])
|
|
211
211
|
|
|
212
212
|
|