claude-multiacc 2.0.50 → 2.0.52

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.
Files changed (74) hide show
  1. package/bin/codex +55 -11
  2. package/bin/codex-accounts +34 -40
  3. package/bin/multiacc-select +6 -1
  4. package/docs/CODEX.md +118 -0
  5. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  6. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  28. package/lib/autoresume.py +7 -2
  29. package/lib/autoresume_probe.py +3 -3
  30. package/lib/codex_credit_controls.py +27 -0
  31. package/lib/codex_credit_local_scope.py +35 -0
  32. package/lib/codex_credit_selection.py +172 -0
  33. package/lib/codex_credits.py +158 -0
  34. package/lib/codex_limits_lock.py +56 -0
  35. package/lib/codex_usage_fetch.py +85 -0
  36. package/lib/report.py +6 -0
  37. package/lib/selector_credit_scope.py +85 -0
  38. package/lib/selector_credits.py +105 -0
  39. package/lib/selector_funding.py +113 -0
  40. package/lib/selector_policy.py +20 -2
  41. package/lib/selector_primitives.py +20 -2
  42. package/package.json +1 -1
  43. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  67. package/tests/test_codex_reset_credits.py +154 -0
  68. package/tests/test_codex_reset_credits_polling.py +151 -0
  69. package/tests/test_codex_reset_credits_report.py +85 -0
  70. package/tests/test_codex_reset_fetch.py +123 -0
  71. package/tests/test_selector_cached_credits.py +135 -0
  72. package/tests/test_selector_credit_scope.py +118 -0
  73. package/tests/test_selector_credit_shim.py +139 -0
  74. package/tests/test_selector_credits.py +140 -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=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
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) ar_mark_cooldown "$ACC_ROOT/$acct" ;;
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 unknown wallets, authentication errors, 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} session-ok=${PICK_GATE_COUNT} pwd=$PWD"
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" ] && [ "${#eligible[@]}" -ge 2 ]; then
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
- if grep -qiE "$LIMITPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
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=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
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=$((RANDOM % n))
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))]}"
@@ -1075,6 +1075,12 @@ cmd_relogin() {
1075
1075
  [ -z "$failed" ]
1076
1076
  }
1077
1077
 
1078
+ codex_limits_lock_release() {
1079
+ [ -n "${LIMITS_LOCK:-}" ] && "$PYBIN" "$LIB_DIR/codex_limits_lock.py" release "$LIMITS_LOCK" "$$"
1080
+ LIMITS_LOCK=""
1081
+ return 0
1082
+ }
1083
+
1078
1084
  cmd_limits() {
1079
1085
  require_manifest
1080
1086
  local quiet=0 force=0 json=0
@@ -1088,23 +1094,14 @@ cmd_limits() {
1088
1094
  done
1089
1095
  local lock="$ACC_ROOT/.locks/limits"
1090
1096
  mkdir -p "$ACC_ROOT/.locks"
1091
- if ! mkdir "$lock" 2>/dev/null; then
1092
- local age=$(( $(epoch_now) - $(file_mtime "$lock") ))
1093
- if [ "$age" -lt 120 ]; then
1094
- # A --json caller still gets the document (built from the state on disk) —
1095
- # a machine-readable verb must never answer a concurrent run with silence.
1096
- if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
1097
- [ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
1098
- return 0
1099
- fi
1100
- rm -rf "$lock"
1101
- if ! mkdir "$lock" 2>/dev/null; then
1102
- if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
1103
- return 0
1104
- fi
1097
+ if ! "$PYBIN" "$LIB_DIR/codex_limits_lock.py" acquire "$lock" "$$"; then
1098
+ # JSON callers still get the cached document while a live refresh owns the lock.
1099
+ if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
1100
+ [ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
1101
+ return 0
1105
1102
  fi
1106
1103
  LIMITS_LOCK="$lock"
1107
- trap limits_lock_release EXIT
1104
+ trap codex_limits_lock_release EXIT
1108
1105
  rotate_log limits.log
1109
1106
  # The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
1110
1107
  # never loosen it, or an account could sit at 95% and still be selected.
@@ -1120,7 +1117,8 @@ root, threshold, quiet, url, force, lib_dir = (sys.argv[1], int(sys.argv[2]),
1120
1117
  sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1', sys.argv[6])
1121
1118
  sys.path.insert(0, lib_dir)
1122
1119
  from codex_reset_telemetry import refresh_reset_credits
1123
- now = time.time()
1120
+ from codex_credits import collect_credits, failed_credits, record_read_failure
1121
+ from codex_usage_fetch import fetch_usage, failure_kind, retry_delay
1124
1122
  # Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
1125
1123
  MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '240'))
1126
1124
 
@@ -1395,6 +1393,7 @@ def refresh_oauth(aid, d, cpath):
1395
1393
  return tok
1396
1394
 
1397
1395
  for acct in manifest.get('accounts', []):
1396
+ now = time.time()
1398
1397
  aid = acct['id']
1399
1398
  d = os.path.join(root, aid)
1400
1399
  if not os.path.isdir(d):
@@ -1417,7 +1416,8 @@ for acct in manifest.get('accounts', []):
1417
1416
  continue
1418
1417
  retry_at = prev.get('retry_after', 0)
1419
1418
  if retry_at > now:
1420
- say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
1419
+ reason = '429' if prev.get('last_error') in (None, 'http_429') else 'usage read failure'
1420
+ say(f'{aid}: backing off after {reason} ({int(retry_at - now)}s left); limits left as-is')
1421
1421
  continue
1422
1422
 
1423
1423
  bearer = None
@@ -1447,6 +1447,7 @@ for acct in manifest.get('accounts', []):
1447
1447
  if not bearer:
1448
1448
  # Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
1449
1449
  say(f'{aid}: no fresh bearer (expired or missing chatgpt auth); limits left as-is')
1450
+ record_read_failure(lpath, prev, 'unauthenticated', time.time())
1450
1451
  continue
1451
1452
  if not account_id:
1452
1453
  account_id = str((jwt_claims(bearer).get('https://api.openai.com/auth') or {})
@@ -1462,29 +1463,16 @@ for acct in manifest.get('accounts', []):
1462
1463
  headers['chatgpt-account-id'] = account_id
1463
1464
  req = urllib.request.Request(url, headers=headers)
1464
1465
  try:
1465
- resp = urllib.request.urlopen(req, timeout=15)
1466
- data = json.loads(resp.read().decode())
1467
- except urllib.error.HTTPError as e:
1468
- if e.code == 429:
1469
- # Respect Retry-After; otherwise exponential backoff capped at 30 min.
1470
- try:
1471
- wait = int(e.headers.get('Retry-After') or 0)
1472
- except (TypeError, ValueError):
1473
- wait = 0
1474
- if wait <= 0:
1475
- wait = min(1800, max(120, int(prev.get('backoff', 60)) * 2))
1476
- prev['retry_after'] = int(now + wait)
1477
- 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
- say(f'{aid}: rate limited (429); backing off {wait}s; failing open')
1483
- else:
1484
- say(f'{aid}: usage fetch failed (HTTP {e.code}); failing open')
1485
- continue
1466
+ data = fetch_usage(req)
1467
+ now = time.time()
1486
1468
  except Exception as e:
1487
- say(f'{aid}: usage fetch failed ({e}); failing open')
1469
+ now = time.time()
1470
+ error = failure_kind(e)
1471
+ wait = retry_delay(e, prev, now)
1472
+ prev.update(retry_after=int(now + wait), backoff=wait)
1473
+ label = 'rate limited (429)' if error == 'http_429' else f'usage fetch failed ({error})'
1474
+ say(f'{aid}: {label}; backing off {wait}s; failing open')
1475
+ record_read_failure(lpath, prev, error, now)
1488
1476
  continue
1489
1477
 
1490
1478
  def pct_of(v):
@@ -1594,6 +1582,7 @@ for acct in manifest.get('accounts', []):
1594
1582
  walk(data, [])
1595
1583
  except Exception as e:
1596
1584
  say(f'{aid}: unexpected usage payload shape ({e}); failing open')
1585
+ record_read_failure(lpath, prev, 'invalid_usage', time.time())
1597
1586
  continue
1598
1587
  # Successful fetch: fresh buckets replace everything, backoff state is dropped.
1599
1588
  # THREE selection signals, same reset asymmetry as the claude pool:
@@ -1662,6 +1651,9 @@ for acct in manifest.get('accounts', []):
1662
1651
  b.pop('_reset_known', None)
1663
1652
  out = {'fetched_at': int(now), 'source': 'chatgpt'}
1664
1653
  out.update(reset_view)
1654
+ credits = collect_credits(data, account_id, now, prev.get('credits'))
1655
+ if credits is not None:
1656
+ out['credits'] = credits
1665
1657
  if live:
1666
1658
  out['max_percent'] = maxp
1667
1659
  if weeklyp is not None:
@@ -1694,6 +1686,8 @@ for acct in manifest.get('accounts', []):
1694
1686
  # refreshed account; the next scheduled pass replaces it with server truth.
1695
1687
  out['fetched_at'] = 0
1696
1688
  out['auto_reset'] = reset_result
1689
+ if credits is not None:
1690
+ out['credits'] = failed_credits(credits, 'usage_reset_pending', now)
1697
1691
  tmp = lpath + '.tmp'
1698
1692
  with open(tmp, 'w') as f:
1699
1693
  json.dump(out, f, indent=1)
@@ -1841,7 +1835,7 @@ PYEOF
1841
1835
  local refresh_rc=$?
1842
1836
  # Release the lock BEFORE the report: a --json caller must not hold the refresh
1843
1837
  # lock while a consumer reads its output.
1844
- limits_lock_release
1838
+ codex_limits_lock_release
1845
1839
  trap - EXIT
1846
1840
  # The refresher fails open per account; a NON-zero status means the pass itself
1847
1841
  # broke (unreadable manifest, dead python). Report the state anyway — stale data
@@ -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,124 @@ 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. This freshness
114
+ label is informational for ordinary credit admission: a previously confirmed positive balance
115
+ or explicit unlimited allowance remains usable through failed refreshes and old observations,
116
+ without a fallback age limit. A failed usage HTTP 401/403 is a telemetry failure, not proof
117
+ that the native CLI's inference login is invalid. Missing authentication, an identity mismatch,
118
+ failed refusal-record access, actual authentication parks and provider spending restrictions
119
+ still deny admission. Invalid or more than 30-seconds-future timestamps never authorize it.
120
+ An account with no previous wallet stays unknown after a failed read. Reports never expose raw
121
+ provider errors, arbitrary response fields or authentication material through this object.
122
+ Partial wallet payloads retain measured funds and can add explicit provider spending restrictions;
123
+ they never clear a previous restriction. A successful full wallet read is required to clear it.
124
+ A successful earned reset marks pre-reset admission facts `usage_reset_pending` until
125
+ the next usage read. Cached funds remain usable during that gap; the selector may still label
126
+ the capacity as credit-backed until telemetry confirms the refill. The native Codex client
127
+ uses the provider's live allowance and determines actual billing independently of that label.
128
+
129
+ Collection adds no separate balance request, inference, purchase, redemption or billing-mode
130
+ change. The existing usage GET retries a transient routing, DNS, timeout or 5xx failure once
131
+ within a 15-second monotonic budget, with each socket timeout capped at 10 seconds; operating-system
132
+ DNS/socket operations can exceed that budget. HTTP 401/403/429 and responses with `Retry-After`
133
+ are never retried immediately. Failures back off per account, honoring `Retry-After` seconds or
134
+ dates, otherwise doubling from 120 seconds to 900 seconds (1800 for 429). Failed polls keep
135
+ confirmed balances and their original timestamps. Successful observations use the response time,
136
+ not the start of a potentially slow whole-pool pass. Logs and reports distinguish
137
+ `network_unreachable`, `dns_unavailable`, `usage_timeout`, `connection_reset`, `tls_error` and
138
+ `edge_challenge` without exposing raw errors. A live collector's PID-and-birth-bound lock does
139
+ not expire after two minutes; dead collectors' locks can be reclaimed without overlapping a live pass.
140
+
141
+ Earned `usage.reset_credits_available` remain separate: they reset included
142
+ quota, whereas this wallet funds usage after included quota is exhausted. API-key
143
+ authentication remains unsupported. Consumers must preserve the wallet's timestamp,
144
+ identity, spend-control refusals and failure state when deciding whether credit-backed
145
+ work is possible; `has_credits: true` alone is insufficient.
146
+ The shim's wallet-and-member-bound paid-refusal receipt is reported separately as
147
+ `usage.credit_blocked_at`, `usage.credit_blocked_balance`, and `usage.limited_reason: "credits"`.
148
+ A genuine paid quota refusal writes `.credit-refused.json` and a `.limited` marker with
149
+ `reason=credit-refused`. Authentication and temporary service errors do not create credit
150
+ refusals. Paid work can retry after 600 seconds and a strictly newer successful, fresh observation;
151
+ a newer increased balance permits earlier recovery, also requiring a successful fresh read.
152
+ A cached positive balance alone never clears an actual paid refusal. Confirmed finite zero,
153
+ missing monetary observations and provider spending restrictions still deny paid work.
154
+ Included quota can recover independently.
155
+ Copies of the same member share the receipt; different known members of a workspace do not.
156
+
157
+ ### Credit-aware selection
158
+
159
+ The standalone shim keeps its session gate and weekly band for included quota, then tries
160
+ its existing 90–99% reserve before spendable credits. It uses the existing ChatGPT login;
161
+ no API-key switch, re-login or billing toggle is needed. `CODEX_MULTIACC_CREDITS=0` disables
162
+ this fallback. Explicit account pins continue to represent the caller's choice.
163
+
164
+ `multiacc-select --capabilities` advertises `codex_credits_v1`. A caller enables the additive
165
+ policy with `credit_fallback: true` and may request only paid candidates through
166
+ `required_funding_tier: "credits"`. `credit_max_age_seconds` defaults to 900 (accepted range
167
+ 30–3600) for freshness reporting and paid-refusal recovery. It does not expire a confirmed
168
+ positive balance for ordinary credit admission. Observations more than 30 seconds in the future
169
+ cannot authorize spending. Optional candidate fields are:
170
+
171
+ | Field | Meaning |
172
+ | --- | --- |
173
+ | `credits` | The sanitized wallet observation shown above. |
174
+ | `credit_capable` | Caller-confirmed runner support; must be true for paid admission. |
175
+ | `credit_active_holders` | Active holders of this shared wallet, not just the local slot. |
176
+ | `credit_actor_email` | Normalized member email, or null when unknown. |
177
+ | `credit_blocked_at`, `credit_blocked_balance` | The latest actual paid-refusal receipt. |
178
+ | `limited_reason` | `usage` for included quota, `credits` for a paid refusal; other parks remain blocked. |
179
+
180
+ Successful opted-in responses include `credit_capability`, `funding_tier` (`included`,
181
+ `reserve`, or `credits`) and corresponding score fields. Credit scores carry the decimal
182
+ balance, billing identity, unlimited flag and active holder count; when supplied, the actor
183
+ email is also preserved in the normalized proof and score. New monetary observations replace
184
+ older balances across wallet copies; balances are never summed or merged by maximum.
185
+ Financial and member-control observations are ordered by their actual `fetched_at`, never a
186
+ later failed refresh's `last_error_at`. An old cached positive balance cannot replace a newer
187
+ confirmed zero; an old cached allowance cannot erase a newer confirmed member restriction.
188
+ Explicit unlimited allowance remains distinct from finite balances, including null or zero.
189
+ Member permissions, actor observation timestamps and local authentication/identity failures
190
+ remain separate from shared finances. Refusals propagate only within the same wallet/member
191
+ identity; an unknown member's refusal stays with its runner and account slot. Failed or partial reads
192
+ retain restrictive member controls until that source successfully refreshes them. Paid ranking compares
193
+ balance per active holder, then uses the existing deterministic rotation for ties.
194
+
195
+ The schema and selector version remain `pool-selection.v2` / `2.0.1`. Requests without the
196
+ credit opt-in retain their prior outputs and digests. Extra credit candidate fields without
197
+ opt-in are rejected. Callers must check the capability before issuing paid requests; an old
198
+ runner must never silently execute a decision it cannot enforce.
199
+
82
200
  ```bash
83
201
  codex-accounts add # sign in a new ChatGPT account (device code —
84
202
  # open the URL in any browser, enter the code)
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)
@@ -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,27 @@
1
+ """Normalize explicit provider spending controls independently of wallet amounts."""
2
+
3
+ _LIMIT_TYPES = {
4
+ "rate_limit_reached", "workspace_owner_credits_depleted", "workspace_member_credits_depleted",
5
+ "workspace_owner_usage_limit_reached", "workspace_member_usage_limit_reached",
6
+ }
7
+
8
+
9
+ def limit_type(value):
10
+ if isinstance(value, dict):
11
+ value = value.get("type", "unknown")
12
+ if value is None:
13
+ return None
14
+ return value if isinstance(value, str) and value in _LIMIT_TYPES else "unknown"
15
+
16
+
17
+ def usage_controls(usage, root):
18
+ limit = root.get("rate_limit")
19
+ limit = limit if isinstance(limit, dict) else {}
20
+ ordinary = usage.get("ordinary_usage_allowed", usage.get("ordinaryUsageAllowed", limit.get("allowed")))
21
+ reached = root.get("rate_limit_reached_type", root.get("rateLimitReachedType", limit.get("limit_reached_type")))
22
+ spend = root.get("spend_control")
23
+ spend = spend if isinstance(spend, dict) else {}
24
+ spend_reached = root.get("spend_control_reached", root.get("spendControlReached", spend.get("reached")))
25
+ return {"ordinary_usage_allowed": ordinary if type(ordinary) is bool else None,
26
+ "spend_control_reached": spend_reached if type(spend_reached) is bool else None,
27
+ "limit_reached_type": limit_type(reached)}