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.
Files changed (64) hide show
  1. package/bin/codex +55 -11
  2. package/bin/codex-accounts +11 -4
  3. package/bin/multiacc-select +6 -1
  4. package/docs/CODEX.md +90 -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_local_scope.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  25. package/lib/autoresume.py +7 -2
  26. package/lib/autoresume_probe.py +3 -3
  27. package/lib/codex_credit_local_scope.py +35 -0
  28. package/lib/codex_credit_selection.py +166 -0
  29. package/lib/codex_credits.py +162 -0
  30. package/lib/report.py +6 -0
  31. package/lib/selector_credit_scope.py +55 -0
  32. package/lib/selector_credits.py +99 -0
  33. package/lib/selector_funding.py +111 -0
  34. package/lib/selector_policy.py +20 -2
  35. package/lib/selector_primitives.py +20 -2
  36. package/package.json +1 -1
  37. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  38. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  39. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  40. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  41. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  42. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  43. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  59. package/tests/test_codex_reset_credits.py +112 -0
  60. package/tests/test_codex_reset_credits_polling.py +94 -0
  61. package/tests/test_codex_reset_credits_report.py +85 -0
  62. package/tests/test_selector_credit_scope.py +118 -0
  63. package/tests/test_selector_credit_shim.py +139 -0
  64. 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=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 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} 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))]}"
@@ -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)
@@ -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)
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,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})