claude-multiacc 2.0.50 → 2.0.51
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/codex +55 -11
- package/bin/codex-accounts +11 -4
- package/bin/multiacc-select +6 -1
- package/docs/CODEX.md +90 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.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/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/autoresume.py +7 -2
- package/lib/autoresume_probe.py +3 -3
- package/lib/codex_credit_local_scope.py +35 -0
- package/lib/codex_credit_selection.py +166 -0
- package/lib/codex_credits.py +162 -0
- package/lib/report.py +6 -0
- package/lib/selector_credit_scope.py +55 -0
- package/lib/selector_credits.py +99 -0
- package/lib/selector_funding.py +111 -0
- package/lib/selector_policy.py +20 -2
- package/lib/selector_primitives.py +20 -2
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/test_codex_reset_credits.py +112 -0
- package/tests/test_codex_reset_credits_polling.py +94 -0
- package/tests/test_codex_reset_credits_report.py +85 -0
- package/tests/test_selector_credit_scope.py +118 -0
- package/tests/test_selector_credit_shim.py +139 -0
- package/tests/test_selector_credits.py +139 -0
package/bin/codex
CHANGED
|
@@ -468,7 +468,7 @@ limited_hard_blocked() { # $1 acct dir
|
|
|
468
468
|
local m="$1/.limited" line="" p
|
|
469
469
|
if [ -f "$m" ]; then
|
|
470
470
|
line="$(sed -n 2p "$m" 2>/dev/null)"
|
|
471
|
-
case "$line" in *reason=client-rate-limit*|*reason=error-cooldown*) return 0 ;; esac
|
|
471
|
+
case "$line" in *reason=client-rate-limit*|*reason=error-cooldown*|*reason=credit-refused*) return 0 ;; esac
|
|
472
472
|
p="$(limited_percent_of "$1")"
|
|
473
473
|
if [ "$p" -ge 0 ] 2>/dev/null; then
|
|
474
474
|
[ "$p" -ge 100 ]
|
|
@@ -841,7 +841,7 @@ ar_set_hist() { # $1 comma list class:epoch -> AR_HIST: well-formed entries, new
|
|
|
841
841
|
# rollout line must not pin an account out for days — a peer may have redeemed its reset
|
|
842
842
|
# in the meantime. The scheduled 5-minute limits pass marks the real window properly.
|
|
843
843
|
ar_mark_cooldown() { # $1 acct dir
|
|
844
|
-
local m="$1/.limited" cur="" until=$((now + 600))
|
|
844
|
+
local m="$1/.limited" cur="" until=$((now + 600)) reason="${2:-error-cooldown}"
|
|
845
845
|
if [ -f "$m" ]; then
|
|
846
846
|
IFS= read -r cur < "$m" 2>/dev/null || cur=""
|
|
847
847
|
num_ok "$cur" || cur=0
|
|
@@ -849,7 +849,7 @@ ar_mark_cooldown() { # $1 acct dir
|
|
|
849
849
|
fi
|
|
850
850
|
{
|
|
851
851
|
echo "$until"
|
|
852
|
-
echo "bucket
|
|
852
|
+
echo "bucket=$reason percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=$reason"
|
|
853
853
|
} 2>/dev/null > "$1/.limited.$$" \
|
|
854
854
|
&& mv -f "$1/.limited.$$" "$m" 2>/dev/null \
|
|
855
855
|
|| rm -f "$1/.limited.$$" 2>/dev/null || true
|
|
@@ -896,6 +896,7 @@ ar_token_dead() { # $1 session id (the token's, else the file's) or empty
|
|
|
896
896
|
ar_relaunch() { # $1 token
|
|
897
897
|
local tok="$1" base f line k v n=0 a age
|
|
898
898
|
local ver="" prov="" cls="" acct="" sid="" cwd="" depth="" avoid="" hist="" chain=""
|
|
899
|
+
local funding="" funding_pid=""
|
|
899
900
|
ar_word_ok "$tok" 8 || return 1
|
|
900
901
|
base="$AR_DIR/r-$tok"
|
|
901
902
|
f="$base.relaunch"
|
|
@@ -919,6 +920,8 @@ ar_relaunch() { # $1 token
|
|
|
919
920
|
avoid) avoid="$v" ;;
|
|
920
921
|
hist) hist="$v" ;;
|
|
921
922
|
chain) ar_word_ok "$v" 1 && chain="$v" ;;
|
|
923
|
+
funding_tier) [ "$v" = credits ] && funding=credits ;;
|
|
924
|
+
funding_pid) num_ok "$v" && funding_pid="$v" ;;
|
|
922
925
|
esac
|
|
923
926
|
done < "$f"
|
|
924
927
|
age=$((now - $(file_mtime "$f")))
|
|
@@ -939,7 +942,16 @@ ar_relaunch() { # $1 token
|
|
|
939
942
|
fi
|
|
940
943
|
if [ -n "$acct" ] && [ -d "$ACC_ROOT/$acct" ]; then
|
|
941
944
|
case "$cls" in
|
|
942
|
-
quota)
|
|
945
|
+
quota)
|
|
946
|
+
if [ "$funding" = credits ] && [ -n "$funding_pid" ]; then
|
|
947
|
+
"$MCP_PYBIN" "$SELF_DIR/../lib/codex_credit_selection.py" \
|
|
948
|
+
refuse "$ACC_ROOT/$acct" "$funding_pid" 2>/dev/null || true
|
|
949
|
+
fi
|
|
950
|
+
if [ "$funding" = credits ]; then
|
|
951
|
+
ar_mark_cooldown "$ACC_ROOT/$acct" credit-refused
|
|
952
|
+
else
|
|
953
|
+
ar_mark_cooldown "$ACC_ROOT/$acct"
|
|
954
|
+
fi ;;
|
|
943
955
|
auth) ar_mark_auth "$ACC_ROOT/$acct" ;;
|
|
944
956
|
esac
|
|
945
957
|
fi
|
|
@@ -1073,6 +1085,7 @@ ar_spawn_watcher() { # $1 provider, $2 picked acct dir, then the argv codex is a
|
|
|
1073
1085
|
printf 'acct=%s\nacct_dir=%s\ncwd=%s\nlaunched=%s\n' "${pick##*/}" "$pick" "$PWD" "$(date +%s)"
|
|
1074
1086
|
printf 'tmux=%s\npane=%s\nself=%s\nacc_root=%s\n' "$TMUX" "$TMUX_PANE" "$SELF" "$root"
|
|
1075
1087
|
printf 'depth=%s\navoid=%s\nhist=%s\nchain=%s\n' "$AR_DEPTH" "$AR_AVOID" "$AR_HIST" "$AR_CHAIN"
|
|
1088
|
+
printf 'funding_tier=%s\n' "$AR_TIER"
|
|
1076
1089
|
} 2>/dev/null > "$st.tmp" && mv -f "$st.tmp" "$st" 2>/dev/null || {
|
|
1077
1090
|
rm -f "$st.tmp" "$AR_DIR/$$.argv" 2>/dev/null
|
|
1078
1091
|
return 1
|
|
@@ -1320,6 +1333,12 @@ else
|
|
|
1320
1333
|
for d in "${valid[@]}"; do
|
|
1321
1334
|
if ar_avoided "$d" || limited_hard_blocked "$d"; then hard+=("$d"); else soft+=("$d"); fi
|
|
1322
1335
|
done
|
|
1336
|
+
paid=()
|
|
1337
|
+
if [ "${#hard[@]}" -gt 0 ]; then
|
|
1338
|
+
for d in "${hard[@]}"; do
|
|
1339
|
+
ar_avoided "$d" || paid+=("$d")
|
|
1340
|
+
done
|
|
1341
|
+
fi
|
|
1323
1342
|
# A probe only ASKS: the fallback lines below would claim a pick nobody launched.
|
|
1324
1343
|
if [ "${#soft[@]}" -gt 0 ]; then
|
|
1325
1344
|
AR_TIER=soft
|
|
@@ -1328,6 +1347,15 @@ else
|
|
|
1328
1347
|
PICK_STRICT=0
|
|
1329
1348
|
[ "$AR_PROBE" = "1" ] \
|
|
1330
1349
|
|| sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
|
|
1350
|
+
# Keep the same ChatGPT login. The helper never enables API keys and fails closed
|
|
1351
|
+
# on stale/unknown wallets, spend controls and unresolved paid refusals.
|
|
1352
|
+
elif [ "${CODEX_MULTIACC_CREDITS:-1}" != "0" ] && [ "${#paid[@]}" -gt 0 ] \
|
|
1353
|
+
&& credit_pick="$("$MCP_PYBIN" "$SELF_DIR/../lib/codex_credit_selection.py" \
|
|
1354
|
+
pick "$now" "${paid[@]}" 2>/dev/null)" && [ -n "$credit_pick" ]; then
|
|
1355
|
+
AR_TIER=credits
|
|
1356
|
+
PICK_DIR="$credit_pick"
|
|
1357
|
+
PICK_BAND_COUNT=0
|
|
1358
|
+
PICK_GATE_COUNT=0
|
|
1331
1359
|
else
|
|
1332
1360
|
AR_TIER=hard
|
|
1333
1361
|
# Every account is exhausted RIGHT NOW: nothing serves, so hand out the one that
|
|
@@ -1384,6 +1412,9 @@ fi
|
|
|
1384
1412
|
# CODEX_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
|
|
1385
1413
|
# not a turn in the rotation.
|
|
1386
1414
|
remember_pick "$pick"
|
|
1415
|
+
if [ "$AR_TIER" = "credits" ]; then
|
|
1416
|
+
"$MCP_PYBIN" "$SELF_DIR/../lib/codex_credit_selection.py" record "$pick" "$$" 2>/dev/null || true
|
|
1417
|
+
fi
|
|
1387
1418
|
# The picked account carries every registered MCP server before the client starts.
|
|
1388
1419
|
mcp_ensure "$pick"
|
|
1389
1420
|
|
|
@@ -1408,9 +1439,12 @@ if [ "$stale" = 1 ] && [ -x "$SELF_DIR/codex-accounts" ]; then
|
|
|
1408
1439
|
fi
|
|
1409
1440
|
|
|
1410
1441
|
acct="$(basename "$pick")"
|
|
1442
|
+
credit_funding_log=""
|
|
1443
|
+
[ "$AR_TIER" = "credits" ] && credit_funding_log=" funding=credits"
|
|
1411
1444
|
sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')%" \
|
|
1412
1445
|
"session=$(fresh_field "$pick" session_percent || echo '?')%" \
|
|
1413
|
-
"band=${HEADROOM_BAND} band-count=${PICK_BAND_COUNT} session-gate=${SESSION_GATE_LOG}
|
|
1446
|
+
"band=${HEADROOM_BAND} band-count=${PICK_BAND_COUNT} session-gate=${SESSION_GATE_LOG}" \
|
|
1447
|
+
"session-ok=${PICK_GATE_COUNT} pwd=$PWD${credit_funding_log}"
|
|
1414
1448
|
|
|
1415
1449
|
export CODEX_SHIM_ACTIVE=1
|
|
1416
1450
|
|
|
@@ -1427,7 +1461,8 @@ fi
|
|
|
1427
1461
|
# A service-spawned pipe that never EOFs must take the plain exec path, or the
|
|
1428
1462
|
# stdin pre-buffering below would hang the call.
|
|
1429
1463
|
wants_retry=0
|
|
1430
|
-
if [ "${CODEX_SHIM_RETRY:-1}" != "0" ]
|
|
1464
|
+
if [ "${CODEX_SHIM_RETRY:-1}" != "0" ] \
|
|
1465
|
+
&& { [ "${#eligible[@]}" -ge 2 ] || [ "$AR_TIER" = "credits" ]; }; then
|
|
1431
1466
|
for a in "$@"; do
|
|
1432
1467
|
case "$a" in exec|e) wants_retry=1; break ;; esac
|
|
1433
1468
|
done
|
|
@@ -1493,8 +1528,10 @@ fi
|
|
|
1493
1528
|
# the pool on its own — the shim's guess must never outlive the evidence for it.
|
|
1494
1529
|
PARK_AUTH='not (logged|signed) in|authentication (required|failed)|please run `?codex login|run `?codex login`? to|401 unauthorized|token .{0,12}(expired|revoked)|refresh token.{0,20}(expired|invalid|revoked)|invalid_grant|could not refresh'
|
|
1495
1530
|
PARK_ORG='disabled by (your )?(workspace )?admin|admin (has )?disabled|(workspace|organization) has disabled (codex|chatgpt)|codex.{0,20}disabled for (your|this) (workspace|organization)'
|
|
1531
|
+
CREDIT_LIMITPAT='usage_limit_exceeded|usage limit|weekly limit|quota exceeded|insufficient.{0,20}credits'
|
|
1532
|
+
CREDIT_LIMITPAT+='|out of credits|credits.{0,20}exhausted|spend.control'
|
|
1496
1533
|
LIMITPAT='rate[ _-]?limit|usage limit|limit (reached|exceeded)|too many requests|"?429"?|quota exceeded|hit your usage limit'
|
|
1497
|
-
ERRPAT="$LIMITPAT|$PARK_AUTH|$PARK_ORG"'|401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)'
|
|
1534
|
+
ERRPAT="$LIMITPAT|$CREDIT_LIMITPAT|$PARK_AUTH|$PARK_ORG"'|401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)'
|
|
1498
1535
|
PARK_SOFT_AUTH=3600 # 1h: a mis-parked healthy account is back within the hour
|
|
1499
1536
|
PARK_SOFT_ORG=21600 # 6h: a workspace policy will not change in minutes
|
|
1500
1537
|
|
|
@@ -1514,8 +1551,14 @@ while :; do
|
|
|
1514
1551
|
# auth word must get the self-expiring cooldown, never a park.
|
|
1515
1552
|
park_reason=""
|
|
1516
1553
|
park_soft=0
|
|
1517
|
-
|
|
1518
|
-
|
|
1554
|
+
cooldown_reason=error-cooldown
|
|
1555
|
+
if grep -qiE "$LIMITPAT|$CREDIT_LIMITPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
1556
|
+
if [ "$AR_TIER" = "credits" ] \
|
|
1557
|
+
&& grep -qiE "$CREDIT_LIMITPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null \
|
|
1558
|
+
&& ! grep -qiE 'not your usage limit|temporarily limiting|overloaded' "$tmpd/out" "$tmpd/err"; then
|
|
1559
|
+
"$MCP_PYBIN" "$SELF_DIR/../lib/codex_credit_selection.py" refuse "$cur" "$$" 2>/dev/null || true
|
|
1560
|
+
cooldown_reason=credit-refused
|
|
1561
|
+
fi
|
|
1519
1562
|
elif grep -qiE "$PARK_ORG" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
1520
1563
|
park_reason="org-blocked"
|
|
1521
1564
|
park_detail="a workspace admin has disabled Codex access for the account"
|
|
@@ -1536,14 +1579,15 @@ while :; do
|
|
|
1536
1579
|
else
|
|
1537
1580
|
{
|
|
1538
1581
|
echo $((now + 600))
|
|
1539
|
-
echo "bucket
|
|
1582
|
+
echo "bucket=$cooldown_reason percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=$cooldown_reason"
|
|
1540
1583
|
} 2>/dev/null > "$cur/.limited.$$" \
|
|
1541
1584
|
&& mv -f "$cur/.limited.$$" "$cur/.limited" 2>/dev/null \
|
|
1542
1585
|
|| rm -f "$cur/.limited.$$" 2>/dev/null || true
|
|
1543
1586
|
fi
|
|
1544
1587
|
next=""
|
|
1545
1588
|
n="${#eligible[@]}"
|
|
1546
|
-
start
|
|
1589
|
+
start=0
|
|
1590
|
+
[ "$n" -gt 0 ] && start=$((RANDOM % n))
|
|
1547
1591
|
i=0
|
|
1548
1592
|
while [ "$i" -lt "$n" ]; do
|
|
1549
1593
|
c="${eligible[$(((start + i) % n))]}"
|
package/bin/codex-accounts
CHANGED
|
@@ -1120,6 +1120,7 @@ root, threshold, quiet, url, force, lib_dir = (sys.argv[1], int(sys.argv[2]),
|
|
|
1120
1120
|
sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1', sys.argv[6])
|
|
1121
1121
|
sys.path.insert(0, lib_dir)
|
|
1122
1122
|
from codex_reset_telemetry import refresh_reset_credits
|
|
1123
|
+
from codex_credits import collect_credits, failed_credits, record_read_failure
|
|
1123
1124
|
now = time.time()
|
|
1124
1125
|
# Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
|
|
1125
1126
|
MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '240'))
|
|
@@ -1447,6 +1448,7 @@ for acct in manifest.get('accounts', []):
|
|
|
1447
1448
|
if not bearer:
|
|
1448
1449
|
# Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
|
|
1449
1450
|
say(f'{aid}: no fresh bearer (expired or missing chatgpt auth); limits left as-is')
|
|
1451
|
+
record_read_failure(lpath, prev, 'unauthenticated', now)
|
|
1450
1452
|
continue
|
|
1451
1453
|
if not account_id:
|
|
1452
1454
|
account_id = str((jwt_claims(bearer).get('https://api.openai.com/auth') or {})
|
|
@@ -1475,16 +1477,15 @@ for acct in manifest.get('accounts', []):
|
|
|
1475
1477
|
wait = min(1800, max(120, int(prev.get('backoff', 60)) * 2))
|
|
1476
1478
|
prev['retry_after'] = int(now + wait)
|
|
1477
1479
|
prev['backoff'] = wait
|
|
1478
|
-
tmp = lpath + '.tmp'
|
|
1479
|
-
with open(tmp, 'w') as f:
|
|
1480
|
-
json.dump(prev, f, indent=1)
|
|
1481
|
-
os.replace(tmp, lpath)
|
|
1482
1480
|
say(f'{aid}: rate limited (429); backing off {wait}s; failing open')
|
|
1483
1481
|
else:
|
|
1484
1482
|
say(f'{aid}: usage fetch failed (HTTP {e.code}); failing open')
|
|
1483
|
+
error = f'http_{e.code}' if e.code in (401, 403, 429) else 'http_error'
|
|
1484
|
+
record_read_failure(lpath, prev, error, now)
|
|
1485
1485
|
continue
|
|
1486
1486
|
except Exception as e:
|
|
1487
1487
|
say(f'{aid}: usage fetch failed ({e}); failing open')
|
|
1488
|
+
record_read_failure(lpath, prev, 'usage_read_failed', now)
|
|
1488
1489
|
continue
|
|
1489
1490
|
|
|
1490
1491
|
def pct_of(v):
|
|
@@ -1594,6 +1595,7 @@ for acct in manifest.get('accounts', []):
|
|
|
1594
1595
|
walk(data, [])
|
|
1595
1596
|
except Exception as e:
|
|
1596
1597
|
say(f'{aid}: unexpected usage payload shape ({e}); failing open')
|
|
1598
|
+
record_read_failure(lpath, prev, 'invalid_usage', now)
|
|
1597
1599
|
continue
|
|
1598
1600
|
# Successful fetch: fresh buckets replace everything, backoff state is dropped.
|
|
1599
1601
|
# THREE selection signals, same reset asymmetry as the claude pool:
|
|
@@ -1662,6 +1664,9 @@ for acct in manifest.get('accounts', []):
|
|
|
1662
1664
|
b.pop('_reset_known', None)
|
|
1663
1665
|
out = {'fetched_at': int(now), 'source': 'chatgpt'}
|
|
1664
1666
|
out.update(reset_view)
|
|
1667
|
+
credits = collect_credits(data, account_id, now, prev.get('credits'))
|
|
1668
|
+
if credits is not None:
|
|
1669
|
+
out['credits'] = credits
|
|
1665
1670
|
if live:
|
|
1666
1671
|
out['max_percent'] = maxp
|
|
1667
1672
|
if weeklyp is not None:
|
|
@@ -1694,6 +1699,8 @@ for acct in manifest.get('accounts', []):
|
|
|
1694
1699
|
# refreshed account; the next scheduled pass replaces it with server truth.
|
|
1695
1700
|
out['fetched_at'] = 0
|
|
1696
1701
|
out['auto_reset'] = reset_result
|
|
1702
|
+
if credits is not None:
|
|
1703
|
+
out['credits'] = failed_credits(credits, 'usage_reset_pending', now)
|
|
1697
1704
|
tmp = lpath + '.tmp'
|
|
1698
1705
|
with open(tmp, 'w') as f:
|
|
1699
1706
|
json.dump(out, f, indent=1)
|
package/bin/multiacc-select
CHANGED
|
@@ -11,6 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
ROOT = Path(__file__).resolve().parents[1]
|
|
12
12
|
sys.path.insert(0, str(ROOT / "lib"))
|
|
13
13
|
|
|
14
|
+
from selector_credits import CAPABILITY # noqa: E402
|
|
14
15
|
from selector_policy import select # noqa: E402
|
|
15
16
|
from selector_primitives import SCHEMA, SELECTOR_VERSION # noqa: E402
|
|
16
17
|
|
|
@@ -32,7 +33,11 @@ def _strict_object(pairs: list[tuple[str, object]]) -> dict:
|
|
|
32
33
|
def main() -> int:
|
|
33
34
|
if sys.argv[1:] in (["--help"], ["-h"]):
|
|
34
35
|
print("usage: multiacc-select --request-json - --response-json -\n"
|
|
35
|
-
" multiacc-select --version")
|
|
36
|
+
" multiacc-select --version|--capabilities")
|
|
37
|
+
return 0
|
|
38
|
+
if sys.argv[1:] == ["--capabilities"]:
|
|
39
|
+
print(json.dumps({"schema": SCHEMA, "selector_version": SELECTOR_VERSION,
|
|
40
|
+
"capabilities": [CAPABILITY]}, separators=(",", ":")))
|
|
36
41
|
return 0
|
|
37
42
|
if sys.argv[1:] == ["--version"]:
|
|
38
43
|
print(SELECTOR_VERSION)
|
package/docs/CODEX.md
CHANGED
|
@@ -79,6 +79,96 @@ Codex-specific notes:
|
|
|
79
79
|
- **API-key logins are rejected** — ChatGPT subscription accounts only, matching the
|
|
80
80
|
addon's no-API-keys rule.
|
|
81
81
|
|
|
82
|
+
## Spendable Codex credits
|
|
83
|
+
|
|
84
|
+
The existing authenticated usage GET also supplies the account's spendable Codex
|
|
85
|
+
credit wallet. `codex-accounts list/status/limits --json` exposes it as `usage.credits`:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"has_credits": true,
|
|
90
|
+
"unlimited": false,
|
|
91
|
+
"balance": "61812.452075",
|
|
92
|
+
"fetched_at": "2026-10-04T12:00:00Z",
|
|
93
|
+
"account_id": "provider-account-id",
|
|
94
|
+
"ordinary_usage_allowed": false,
|
|
95
|
+
"spend_control_reached": false,
|
|
96
|
+
"limit_reached_type": "rate_limit_reached",
|
|
97
|
+
"age_seconds": 0,
|
|
98
|
+
"stale": false
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`balance` is a decimal string in provider credit units, never dollars or a percentage.
|
|
103
|
+
A measured `"0"`, unknown `null`, and `unlimited: true` remain distinct. Missing `credits`
|
|
104
|
+
means no usable observation exists. `account_id` binds the read to the authenticated
|
|
105
|
+
provider account; a conflicting response identity invalidates the observation.
|
|
106
|
+
The two admission booleans and limit type preserve explicit provider facts; missing
|
|
107
|
+
values are `null`, never inferred from usage percentages or reset dates.
|
|
108
|
+
|
|
109
|
+
Wallet freshness uses its own `fetched_at`, independently of the usage windows and
|
|
110
|
+
earned-reset redemption. Failed usage or malformed wallet reads retain the last known
|
|
111
|
+
balance and timestamp with bounded `last_error`/`last_error_at` fields and `stale: true`.
|
|
112
|
+
A successful wallet read clears that failure. Old or future-dated snapshots also become
|
|
113
|
+
stale; the default report horizon is 900 seconds, matching Codex telemetry. An account
|
|
114
|
+
with no previous wallet stays unknown after a failed read. Reports never expose raw
|
|
115
|
+
provider errors, arbitrary response fields or authentication material through this object.
|
|
116
|
+
A successful earned reset marks pre-reset admission facts `usage_reset_pending` until
|
|
117
|
+
the next usage read, so newly refilled included quota is used before paid fallback.
|
|
118
|
+
|
|
119
|
+
Collection adds no network request, inference, purchase, redemption or billing-mode
|
|
120
|
+
change. Earned `usage.reset_credits_available` remain separate: they reset included
|
|
121
|
+
quota, whereas this wallet funds usage after included quota is exhausted. API-key
|
|
122
|
+
authentication remains unsupported. Consumers must preserve the wallet's timestamp,
|
|
123
|
+
identity, spend-control refusals and failure state when deciding whether credit-backed
|
|
124
|
+
work is possible; `has_credits: true` alone is insufficient.
|
|
125
|
+
The shim's wallet-and-member-bound paid-refusal receipt is reported separately as
|
|
126
|
+
`usage.credit_blocked_at`, `usage.credit_blocked_balance`, and `usage.limited_reason: "credits"`.
|
|
127
|
+
A genuine paid quota refusal writes `.credit-refused.json` and a `.limited` marker with
|
|
128
|
+
`reason=credit-refused`. Authentication and temporary service errors do not create credit
|
|
129
|
+
refusals. Paid work can retry after 600 seconds and a strictly newer successful observation;
|
|
130
|
+
a newer increased balance permits earlier recovery. Zero funds, missing observations and
|
|
131
|
+
provider spending restrictions still deny paid work. Included quota can recover independently.
|
|
132
|
+
Copies of the same member share the receipt; different known members of a workspace do not.
|
|
133
|
+
|
|
134
|
+
### Credit-aware selection
|
|
135
|
+
|
|
136
|
+
The standalone shim keeps its session gate and weekly band for included quota, then tries
|
|
137
|
+
its existing 90–99% reserve before spendable credits. It uses the existing ChatGPT login;
|
|
138
|
+
no API-key switch, re-login or billing toggle is needed. `CODEX_MULTIACC_CREDITS=0` disables
|
|
139
|
+
this fallback. Explicit account pins continue to represent the caller's choice.
|
|
140
|
+
|
|
141
|
+
`multiacc-select --capabilities` advertises `codex_credits_v1`. A caller enables the additive
|
|
142
|
+
policy with `credit_fallback: true` and may request only paid candidates through
|
|
143
|
+
`required_funding_tier: "credits"`. `credit_max_age_seconds` defaults to 900 (accepted range
|
|
144
|
+
30–3600); observations more than 30 seconds in the future cannot authorize spending.
|
|
145
|
+
Optional candidate fields are:
|
|
146
|
+
|
|
147
|
+
| Field | Meaning |
|
|
148
|
+
| --- | --- |
|
|
149
|
+
| `credits` | The sanitized wallet observation shown above. |
|
|
150
|
+
| `credit_capable` | Caller-confirmed runner support; must be true for paid admission. |
|
|
151
|
+
| `credit_active_holders` | Active holders of this shared wallet, not just the local slot. |
|
|
152
|
+
| `credit_actor_email` | Normalized member email, or null when unknown. |
|
|
153
|
+
| `credit_blocked_at`, `credit_blocked_balance` | The latest actual paid-refusal receipt. |
|
|
154
|
+
| `limited_reason` | `usage` for included quota, `credits` for a paid refusal; other parks remain blocked. |
|
|
155
|
+
|
|
156
|
+
Successful opted-in responses include `credit_capability`, `funding_tier` (`included`,
|
|
157
|
+
`reserve`, or `credits`) and corresponding score fields. Credit scores carry the decimal
|
|
158
|
+
balance, billing identity, unlimited flag and active holder count; when supplied, the actor
|
|
159
|
+
email is also preserved in the normalized proof and score. New monetary observations replace
|
|
160
|
+
older balances across wallet copies; balances are never summed or merged by maximum.
|
|
161
|
+
Failed monetary observations cannot resurrect an older positive balance. Member permissions,
|
|
162
|
+
actor observation timestamps and local authentication/identity failures remain separate from
|
|
163
|
+
shared finances. Refusals propagate only within the same wallet/member identity; unknown
|
|
164
|
+
actors are handled conservatively. Paid ranking compares balance per active holder, then
|
|
165
|
+
uses the existing deterministic rotation for ties.
|
|
166
|
+
|
|
167
|
+
The schema and selector version remain `pool-selection.v2` / `2.0.1`. Requests without the
|
|
168
|
+
credit opt-in retain their prior outputs and digests. Extra credit candidate fields without
|
|
169
|
+
opt-in are rejected. Callers must check the capability before issuing paid requests; an old
|
|
170
|
+
runner must never silently execute a decision it cannot enforce.
|
|
171
|
+
|
|
82
172
|
```bash
|
|
83
173
|
codex-accounts add # sign in a new ChatGPT account (device code —
|
|
84
174
|
# open the URL in any browser, enter the code)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/autoresume.py
CHANGED
|
@@ -125,9 +125,9 @@ MONTHS = {m: i for i, m in enumerate(
|
|
|
125
125
|
("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"), 1)}
|
|
126
126
|
|
|
127
127
|
STATE_KEYS = ("v", "provider", "pid", "ppid", "acct", "acct_dir", "cwd", "launched", "tmux",
|
|
128
|
-
"pane", "self", "acc_root", "depth", "avoid", "hist", "chain")
|
|
128
|
+
"pane", "self", "acc_root", "depth", "avoid", "hist", "chain", "funding_tier")
|
|
129
129
|
RELAUNCH_KEYS = ("v", "provider", "class", "acct", "reset", "rtype", "marked", "sid", "cwd",
|
|
130
|
-
"depth", "avoid", "hist", "chain")
|
|
130
|
+
"depth", "avoid", "hist", "chain", "funding_tier", "funding_pid")
|
|
131
131
|
HIST_KEEP = 32
|
|
132
132
|
|
|
133
133
|
# ---- knobs --------------------------------------------------------------------------
|
|
@@ -806,6 +806,8 @@ def format_relaunch(fields):
|
|
|
806
806
|
value carrying a line break (or NUL) cannot be written faithfully: ValueError."""
|
|
807
807
|
lines = []
|
|
808
808
|
for key in RELAUNCH_KEYS:
|
|
809
|
+
if key in ("funding_tier", "funding_pid") and key not in fields:
|
|
810
|
+
continue
|
|
809
811
|
val = "" if fields.get(key) is None else str(fields[key])
|
|
810
812
|
if any(c in val for c in "\0\n\r"):
|
|
811
813
|
raise ValueError("unwritable value for " + key)
|
|
@@ -939,6 +941,7 @@ def load_state(path):
|
|
|
939
941
|
"tmux": kv["tmux"], "pane": kv["pane"], "self": kv["self"],
|
|
940
942
|
"acc_root": acc_root, "depth": int(depth),
|
|
941
943
|
"avoid": kv.get("avoid", ""), "hist": kv.get("hist", ""), "chain": chain,
|
|
944
|
+
"funding_tier": "credits" if kv.get("funding_tier") == "credits" else "included",
|
|
942
945
|
}
|
|
943
946
|
|
|
944
947
|
|
|
@@ -1956,6 +1959,8 @@ class Watcher:
|
|
|
1956
1959
|
fields = relaunch_fields(self.provider, verdict, self.acct, self.sid, cwd,
|
|
1957
1960
|
self.st["depth"], avoid,
|
|
1958
1961
|
hist_append(self.st["hist"], cls, now), self.chain)
|
|
1962
|
+
if self.provider == "codex" and self.st.get("funding_tier") == "credits":
|
|
1963
|
+
fields.update(funding_tier="credits", funding_pid=str(self.pid))
|
|
1959
1964
|
try:
|
|
1960
1965
|
keys = send_keys_argvs(sock, self.st["pane"], self.provider, token, self.sid,
|
|
1961
1966
|
self.st["self"], self.root)
|
package/lib/autoresume_probe.py
CHANGED
|
@@ -31,7 +31,7 @@ def parse_probe(out):
|
|
|
31
31
|
pick, tier = match.group(1), match.group(2)
|
|
32
32
|
if pick and not ACCT_RE.match(pick):
|
|
33
33
|
return "", ""
|
|
34
|
-
if tier not in ("eligible", "soft", "hard", "none"):
|
|
34
|
+
if tier not in ("eligible", "soft", "credits", "hard", "none"):
|
|
35
35
|
return "", ""
|
|
36
36
|
return pick, tier
|
|
37
37
|
|
|
@@ -44,7 +44,7 @@ def probe_allows(cls, pick, tier, current):
|
|
|
44
44
|
below-threshold requirement; transient/crash may retry anywhere still usable.
|
|
45
45
|
"""
|
|
46
46
|
if cls == "quota":
|
|
47
|
-
return tier in ("eligible", "soft") and bool(pick) and pick != current
|
|
47
|
+
return tier in ("eligible", "soft", "credits") and bool(pick) and pick != current
|
|
48
48
|
if cls in ROTATE:
|
|
49
49
|
return tier == "eligible" and bool(pick) and pick != current
|
|
50
|
-
return tier in ("eligible", "soft") and bool(pick)
|
|
50
|
+
return tier in ("eligible", "soft", "credits") and bool(pick)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Non-secret local actor identity for shared Codex wallet refusal receipts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from selector_credit_scope import actor_email, actor_key, actor_refusals
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def pool_actor(directory: Path) -> str | None:
|
|
12
|
+
try:
|
|
13
|
+
document = json.loads((directory.parent / "accounts.json").read_text())
|
|
14
|
+
accounts = document.get("accounts") or []
|
|
15
|
+
except (OSError, ValueError, AttributeError):
|
|
16
|
+
return None
|
|
17
|
+
for account in accounts:
|
|
18
|
+
if isinstance(account, dict) and account.get("id") == directory.name:
|
|
19
|
+
return actor_email({"credit_actor_email": account.get("email")})
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def merged_local_refusals(directories, read_json, refusal_view):
|
|
24
|
+
candidates = []
|
|
25
|
+
for item in directories:
|
|
26
|
+
directory = Path(item)
|
|
27
|
+
candidates.append({"credits": read_json(directory / "limits.json").get("credits"),
|
|
28
|
+
"runner_id": str(directory.parent.resolve()), "account_id": directory.name,
|
|
29
|
+
"credit_actor_email": pool_actor(directory), **refusal_view(directory)})
|
|
30
|
+
return actor_refusals(candidates)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def local_refusal_key(directory, credits):
|
|
34
|
+
return actor_key({"credits": credits, "credit_actor_email": pool_actor(directory),
|
|
35
|
+
"runner_id": str(directory.parent.resolve()), "account_id": directory.name})
|