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.
- package/bin/codex +55 -11
- package/bin/codex-accounts +34 -40
- package/bin/multiacc-select +6 -1
- package/docs/CODEX.md +118 -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_controls.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_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.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_controls.py +27 -0
- package/lib/codex_credit_local_scope.py +35 -0
- package/lib/codex_credit_selection.py +172 -0
- package/lib/codex_credits.py +158 -0
- package/lib/codex_limits_lock.py +56 -0
- package/lib/codex_usage_fetch.py +85 -0
- package/lib/report.py +6 -0
- package/lib/selector_credit_scope.py +85 -0
- package/lib/selector_credits.py +105 -0
- package/lib/selector_funding.py +113 -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_fetch.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_cached_credits.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 +154 -0
- package/tests/test_codex_reset_credits_polling.py +151 -0
- package/tests/test_codex_reset_credits_report.py +85 -0
- package/tests/test_codex_reset_fetch.py +123 -0
- package/tests/test_selector_cached_credits.py +135 -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 +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
|
|
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 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}
|
|
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
|
@@ -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 !
|
|
1092
|
-
|
|
1093
|
-
if [ "$
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1466
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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,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)
|
|
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
|
|
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,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)}
|