claude-multiacc 2.0.51 → 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 +1 -1
- package/bin/codex-accounts +28 -41
- package/docs/CODEX.md +43 -15
- 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/codex_credit_controls.py +27 -0
- package/lib/codex_credit_selection.py +10 -4
- package/lib/codex_credits.py +28 -32
- package/lib/codex_limits_lock.py +56 -0
- package/lib/codex_usage_fetch.py +85 -0
- package/lib/selector_credit_scope.py +33 -3
- package/lib/selector_credits.py +10 -4
- package/lib/selector_funding.py +16 -14
- 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 +42 -0
- package/tests/test_codex_reset_credits_polling.py +58 -1
- 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 +5 -5
- package/tests/test_selector_credit_shim.py +3 -3
- package/tests/test_selector_credits.py +3 -2
package/bin/codex
CHANGED
|
@@ -1348,7 +1348,7 @@ else
|
|
|
1348
1348
|
[ "$AR_PROBE" = "1" ] \
|
|
1349
1349
|
|| sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
|
|
1350
1350
|
# Keep the same ChatGPT login. The helper never enables API keys and fails closed
|
|
1351
|
-
# on
|
|
1351
|
+
# on unknown wallets, authentication errors, spend controls and unresolved paid refusals.
|
|
1352
1352
|
elif [ "${CODEX_MULTIACC_CREDITS:-1}" != "0" ] && [ "${#paid[@]}" -gt 0 ] \
|
|
1353
1353
|
&& credit_pick="$("$MCP_PYBIN" "$SELF_DIR/../lib/codex_credit_selection.py" \
|
|
1354
1354
|
pick "$now" "${paid[@]}" 2>/dev/null)" && [ -n "$credit_pick" ]; then
|
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.
|
|
@@ -1121,7 +1118,7 @@ root, threshold, quiet, url, force, lib_dir = (sys.argv[1], int(sys.argv[2]),
|
|
|
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
|
|
1124
|
-
|
|
1121
|
+
from codex_usage_fetch import fetch_usage, failure_kind, retry_delay
|
|
1125
1122
|
# Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
|
|
1126
1123
|
MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '240'))
|
|
1127
1124
|
|
|
@@ -1396,6 +1393,7 @@ def refresh_oauth(aid, d, cpath):
|
|
|
1396
1393
|
return tok
|
|
1397
1394
|
|
|
1398
1395
|
for acct in manifest.get('accounts', []):
|
|
1396
|
+
now = time.time()
|
|
1399
1397
|
aid = acct['id']
|
|
1400
1398
|
d = os.path.join(root, aid)
|
|
1401
1399
|
if not os.path.isdir(d):
|
|
@@ -1418,7 +1416,8 @@ for acct in manifest.get('accounts', []):
|
|
|
1418
1416
|
continue
|
|
1419
1417
|
retry_at = prev.get('retry_after', 0)
|
|
1420
1418
|
if retry_at > now:
|
|
1421
|
-
|
|
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')
|
|
1422
1421
|
continue
|
|
1423
1422
|
|
|
1424
1423
|
bearer = None
|
|
@@ -1448,7 +1447,7 @@ for acct in manifest.get('accounts', []):
|
|
|
1448
1447
|
if not bearer:
|
|
1449
1448
|
# Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
|
|
1450
1449
|
say(f'{aid}: no fresh bearer (expired or missing chatgpt auth); limits left as-is')
|
|
1451
|
-
record_read_failure(lpath, prev, 'unauthenticated',
|
|
1450
|
+
record_read_failure(lpath, prev, 'unauthenticated', time.time())
|
|
1452
1451
|
continue
|
|
1453
1452
|
if not account_id:
|
|
1454
1453
|
account_id = str((jwt_claims(bearer).get('https://api.openai.com/auth') or {})
|
|
@@ -1464,28 +1463,16 @@ for acct in manifest.get('accounts', []):
|
|
|
1464
1463
|
headers['chatgpt-account-id'] = account_id
|
|
1465
1464
|
req = urllib.request.Request(url, headers=headers)
|
|
1466
1465
|
try:
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
except urllib.error.HTTPError as e:
|
|
1470
|
-
if e.code == 429:
|
|
1471
|
-
# Respect Retry-After; otherwise exponential backoff capped at 30 min.
|
|
1472
|
-
try:
|
|
1473
|
-
wait = int(e.headers.get('Retry-After') or 0)
|
|
1474
|
-
except (TypeError, ValueError):
|
|
1475
|
-
wait = 0
|
|
1476
|
-
if wait <= 0:
|
|
1477
|
-
wait = min(1800, max(120, int(prev.get('backoff', 60)) * 2))
|
|
1478
|
-
prev['retry_after'] = int(now + wait)
|
|
1479
|
-
prev['backoff'] = wait
|
|
1480
|
-
say(f'{aid}: rate limited (429); backing off {wait}s; failing open')
|
|
1481
|
-
else:
|
|
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
|
-
continue
|
|
1466
|
+
data = fetch_usage(req)
|
|
1467
|
+
now = time.time()
|
|
1486
1468
|
except Exception as e:
|
|
1487
|
-
|
|
1488
|
-
|
|
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)
|
|
1489
1476
|
continue
|
|
1490
1477
|
|
|
1491
1478
|
def pct_of(v):
|
|
@@ -1595,7 +1582,7 @@ for acct in manifest.get('accounts', []):
|
|
|
1595
1582
|
walk(data, [])
|
|
1596
1583
|
except Exception as e:
|
|
1597
1584
|
say(f'{aid}: unexpected usage payload shape ({e}); failing open')
|
|
1598
|
-
record_read_failure(lpath, prev, 'invalid_usage',
|
|
1585
|
+
record_read_failure(lpath, prev, 'invalid_usage', time.time())
|
|
1599
1586
|
continue
|
|
1600
1587
|
# Successful fetch: fresh buckets replace everything, backoff state is dropped.
|
|
1601
1588
|
# THREE selection signals, same reset asymmetry as the claude pool:
|
|
@@ -1848,7 +1835,7 @@ PYEOF
|
|
|
1848
1835
|
local refresh_rc=$?
|
|
1849
1836
|
# Release the lock BEFORE the report: a --json caller must not hold the refresh
|
|
1850
1837
|
# lock while a consumer reads its output.
|
|
1851
|
-
|
|
1838
|
+
codex_limits_lock_release
|
|
1852
1839
|
trap - EXIT
|
|
1853
1840
|
# The refresher fails open per account; a NON-zero status means the pass itself
|
|
1854
1841
|
# broke (unreadable manifest, dead python). Report the state anyway — stale data
|
package/docs/CODEX.md
CHANGED
|
@@ -110,14 +110,35 @@ Wallet freshness uses its own `fetched_at`, independently of the usage windows a
|
|
|
110
110
|
earned-reset redemption. Failed usage or malformed wallet reads retain the last known
|
|
111
111
|
balance and timestamp with bounded `last_error`/`last_error_at` fields and `stale: true`.
|
|
112
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.
|
|
114
|
-
|
|
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
|
|
115
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.
|
|
116
124
|
A successful earned reset marks pre-reset admission facts `usage_reset_pending` until
|
|
117
|
-
the next usage read
|
|
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.
|
|
118
128
|
|
|
119
|
-
Collection adds no
|
|
120
|
-
change.
|
|
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
|
|
121
142
|
quota, whereas this wallet funds usage after included quota is exhausted. API-key
|
|
122
143
|
authentication remains unsupported. Consumers must preserve the wallet's timestamp,
|
|
123
144
|
identity, spend-control refusals and failure state when deciding whether credit-backed
|
|
@@ -126,9 +147,11 @@ The shim's wallet-and-member-bound paid-refusal receipt is reported separately a
|
|
|
126
147
|
`usage.credit_blocked_at`, `usage.credit_blocked_balance`, and `usage.limited_reason: "credits"`.
|
|
127
148
|
A genuine paid quota refusal writes `.credit-refused.json` and a `.limited` marker with
|
|
128
149
|
`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
|
|
131
|
-
|
|
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.
|
|
132
155
|
Copies of the same member share the receipt; different known members of a workspace do not.
|
|
133
156
|
|
|
134
157
|
### Credit-aware selection
|
|
@@ -141,8 +164,9 @@ this fallback. Explicit account pins continue to represent the caller's choice.
|
|
|
141
164
|
`multiacc-select --capabilities` advertises `codex_credits_v1`. A caller enables the additive
|
|
142
165
|
policy with `credit_fallback: true` and may request only paid candidates through
|
|
143
166
|
`required_funding_tier: "credits"`. `credit_max_age_seconds` defaults to 900 (accepted range
|
|
144
|
-
30–3600)
|
|
145
|
-
|
|
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:
|
|
146
170
|
|
|
147
171
|
| Field | Meaning |
|
|
148
172
|
| --- | --- |
|
|
@@ -158,11 +182,15 @@ Successful opted-in responses include `credit_capability`, `funding_tier` (`incl
|
|
|
158
182
|
balance, billing identity, unlimited flag and active holder count; when supplied, the actor
|
|
159
183
|
email is also preserved in the normalized proof and score. New monetary observations replace
|
|
160
184
|
older balances across wallet copies; balances are never summed or merged by maximum.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|
|
166
194
|
|
|
167
195
|
The schema and selector version remain `pool-selection.v2` / `2.0.1`. Requests without the
|
|
168
196
|
credit opt-in retain their prior outputs and digests. Extra credit candidate fields without
|
|
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
|
|
@@ -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)}
|
|
@@ -14,7 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
|
|
15
15
|
from selector_credits import balance_value, normalize_credits, refusal_recovered
|
|
16
16
|
from selector_funding import credit_rank, wallet_snapshots
|
|
17
|
-
from selector_credit_scope import merge_wallet
|
|
17
|
+
from selector_credit_scope import actor_observations, merge_actor, merge_wallet
|
|
18
18
|
from codex_credit_local_scope import pool_actor, merged_local_refusals, local_refusal_key
|
|
19
19
|
from selector_primitives import canonical_sha256, timestamp
|
|
20
20
|
|
|
@@ -51,9 +51,10 @@ def marker_allows(directory, now):
|
|
|
51
51
|
return False
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
def candidate(directory, now, wallets, refusals):
|
|
54
|
+
def candidate(directory, now, wallets, refusals, observations):
|
|
55
55
|
data = read_json(directory / "limits.json")
|
|
56
56
|
credits = normalize_credits(data.get("credits"), datetime.fromtimestamp(now, timezone.utc))
|
|
57
|
+
credits = merge_actor(credits, observations.get(local_refusal_key(directory, credits)))
|
|
57
58
|
credits = merge_wallet(credits, wallets.get(credits["account_id"]))
|
|
58
59
|
refusal = dict(credit_refusal_view(directory), **refusals.get(local_refusal_key(directory, credits), {}))
|
|
59
60
|
if not credits["available"] or not refusal_recovered(refusal, credits, datetime.fromtimestamp(now, timezone.utc)):
|
|
@@ -88,10 +89,15 @@ def active_holders(root, now, cleanup=False):
|
|
|
88
89
|
|
|
89
90
|
|
|
90
91
|
def pick(now, directories):
|
|
91
|
-
snapshots = [read_json(Path(directory) / "limits.json")
|
|
92
|
+
snapshots = [dict(read_json(Path(directory) / "limits.json"),
|
|
93
|
+
credit_actor_email=pool_actor(Path(directory)),
|
|
94
|
+
runner_id=str(Path(directory).parent.resolve()), account_id=Path(directory).name)
|
|
95
|
+
for directory in directories]
|
|
96
|
+
observations = actor_observations(snapshots, datetime.fromtimestamp(now, timezone.utc), 900)
|
|
92
97
|
wallets = wallet_snapshots(snapshots, datetime.fromtimestamp(now, timezone.utc), 900)
|
|
93
98
|
refusals = merged_local_refusals(directories, read_json, credit_refusal_view)
|
|
94
|
-
rows = [row for directory in directories
|
|
99
|
+
rows = [row for directory in directories
|
|
100
|
+
if (row := candidate(Path(directory), now, wallets, refusals, observations))]
|
|
95
101
|
if not rows:
|
|
96
102
|
return ""
|
|
97
103
|
holders = active_holders(Path(directories[0]).parent, now)
|
package/lib/codex_credits.py
CHANGED
|
@@ -9,17 +9,16 @@ import os
|
|
|
9
9
|
import re
|
|
10
10
|
import time
|
|
11
11
|
|
|
12
|
+
from codex_usage_fetch import TRANSPORT_ERRORS
|
|
13
|
+
from codex_credit_controls import limit_type as _limit_type, usage_controls as _controls
|
|
14
|
+
|
|
12
15
|
_DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\Z")
|
|
13
16
|
_IDENTITY = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,199}\Z")
|
|
14
|
-
_LIMIT_TYPES = {
|
|
15
|
-
"rate_limit_reached", "workspace_owner_credits_depleted", "workspace_member_credits_depleted",
|
|
16
|
-
"workspace_owner_usage_limit_reached", "workspace_member_usage_limit_reached",
|
|
17
|
-
}
|
|
18
17
|
_ERRORS = {
|
|
19
18
|
"missing_credits", "invalid_credits", "account_mismatch", "unauthenticated",
|
|
20
19
|
"usage_read_failed", "invalid_usage", "http_401", "http_403", "http_429", "http_error",
|
|
21
20
|
"usage_reset_pending",
|
|
22
|
-
}
|
|
21
|
+
} | TRANSPORT_ERRORS
|
|
23
22
|
|
|
24
23
|
|
|
25
24
|
def _iso(value: float) -> str:
|
|
@@ -47,39 +46,24 @@ def _optional_bool(value):
|
|
|
47
46
|
return value if type(value) is bool else None
|
|
48
47
|
|
|
49
48
|
|
|
50
|
-
def _limit_type(value):
|
|
51
|
-
if isinstance(value, dict):
|
|
52
|
-
value = value.get("type", "unknown")
|
|
53
|
-
if value is None:
|
|
54
|
-
return None
|
|
55
|
-
return value if isinstance(value, str) and value in _LIMIT_TYPES else "unknown"
|
|
56
|
-
|
|
57
|
-
|
|
58
49
|
def snapshot_from_usage(usage: dict, account_id: str, now: float) -> dict:
|
|
59
50
|
"""Bind the provider's wallet and admission facts to the authenticated account."""
|
|
60
51
|
if not isinstance(usage, dict):
|
|
61
52
|
raise ValueError("invalid_usage")
|
|
62
53
|
root = usage.get("rateLimits")
|
|
63
54
|
root = root if isinstance(root, dict) else usage
|
|
64
|
-
raw = root.get("credits")
|
|
65
|
-
if not isinstance(raw, dict):
|
|
66
|
-
raise ValueError("missing_credits")
|
|
67
|
-
has_credits = raw.get("has_credits", raw.get("hasCredits"))
|
|
68
|
-
if type(has_credits) is not bool or type(raw.get("unlimited")) is not bool:
|
|
69
|
-
raise ValueError("invalid_credits")
|
|
70
55
|
response_id = usage.get("account_id", usage.get("accountId"))
|
|
71
56
|
expected, reported = _identity(account_id), _identity(response_id)
|
|
72
57
|
if response_id is not None and not reported:
|
|
73
58
|
raise ValueError("account_mismatch")
|
|
74
59
|
if expected and reported and expected != reported:
|
|
75
60
|
raise ValueError("account_mismatch")
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
spend_reached = root.get("spend_control_reached", root.get("spendControlReached", spend.get("reached")))
|
|
61
|
+
raw = root.get("credits")
|
|
62
|
+
if not isinstance(raw, dict):
|
|
63
|
+
raise ValueError("missing_credits")
|
|
64
|
+
has_credits = raw.get("has_credits", raw.get("hasCredits"))
|
|
65
|
+
if type(has_credits) is not bool or type(raw.get("unlimited")) is not bool:
|
|
66
|
+
raise ValueError("invalid_credits")
|
|
83
67
|
try:
|
|
84
68
|
balance = _balance(raw.get("balance"))
|
|
85
69
|
except ValueError as error:
|
|
@@ -87,9 +71,7 @@ def snapshot_from_usage(usage: dict, account_id: str, now: float) -> dict:
|
|
|
87
71
|
return {
|
|
88
72
|
"has_credits": has_credits, "unlimited": raw["unlimited"], "balance": balance,
|
|
89
73
|
"fetched_at": _iso(now), "account_id": reported or expected,
|
|
90
|
-
|
|
91
|
-
"spend_control_reached": _optional_bool(spend_reached),
|
|
92
|
-
"limit_reached_type": _limit_type(reached),
|
|
74
|
+
**_controls(usage, root),
|
|
93
75
|
}
|
|
94
76
|
|
|
95
77
|
|
|
@@ -132,9 +114,11 @@ def credit_view(value, now: float | None = None, stale_after: int = 900) -> dict
|
|
|
132
114
|
|
|
133
115
|
|
|
134
116
|
def failed_credits(previous, error: str, now: float) -> dict | None:
|
|
135
|
-
"""
|
|
117
|
+
"""Keep the last measured amount and its age when a telemetry read fails."""
|
|
136
118
|
out = credit_view(previous, now)
|
|
137
119
|
if out is not None:
|
|
120
|
+
if out.get("last_error") in ("account_mismatch", "unauthenticated"):
|
|
121
|
+
return out
|
|
138
122
|
out.update(last_error=error if error in _ERRORS else "usage_read_failed",
|
|
139
123
|
last_error_at=_iso(now), stale=True)
|
|
140
124
|
return out
|
|
@@ -142,9 +126,21 @@ def failed_credits(previous, error: str, now: float) -> dict | None:
|
|
|
142
126
|
|
|
143
127
|
def collect_credits(usage, account_id: str, now: float, previous=None) -> dict | None:
|
|
144
128
|
try:
|
|
145
|
-
|
|
129
|
+
result = snapshot_from_usage(usage, account_id, now)
|
|
130
|
+
if previous and result["has_credits"] and not result["unlimited"] and result["balance"] is None:
|
|
131
|
+
raise ValueError("invalid_credits")
|
|
132
|
+
return result
|
|
146
133
|
except ValueError as error:
|
|
147
|
-
|
|
134
|
+
result = failed_credits(previous, str(error), now)
|
|
135
|
+
if result is not None and str(error) in ("missing_credits", "invalid_credits"):
|
|
136
|
+
root = usage.get("rateLimits")
|
|
137
|
+
root = root if isinstance(root, dict) else usage
|
|
138
|
+
controls = _controls(usage, root)
|
|
139
|
+
if controls["spend_control_reached"] is True:
|
|
140
|
+
result["spend_control_reached"] = True
|
|
141
|
+
if controls["limit_reached_type"] not in (None, "rate_limit_reached"):
|
|
142
|
+
result["limit_reached_type"] = controls["limit_reached_type"]
|
|
143
|
+
return result
|
|
148
144
|
|
|
149
145
|
|
|
150
146
|
def record_read_failure(path: str, previous: dict, error: str, now: float) -> None:
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Keep a live Codex usage collector's lock even when a slow pass exceeds two minutes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def process_birth(pid: int) -> str:
|
|
16
|
+
try:
|
|
17
|
+
os.kill(pid, 0)
|
|
18
|
+
result = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="],
|
|
19
|
+
capture_output=True, text=True, timeout=5, check=False)
|
|
20
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
21
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
22
|
+
return ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def change_lock(path: Path, pid: int, release: bool = False) -> bool:
|
|
26
|
+
"""Serialize ownership changes; the owner process lifetime governs the lease."""
|
|
27
|
+
birth = process_birth(pid)
|
|
28
|
+
if not birth:
|
|
29
|
+
return False
|
|
30
|
+
with path.with_suffix(".guard").open("a") as guard:
|
|
31
|
+
fcntl.flock(guard, fcntl.LOCK_EX)
|
|
32
|
+
owner = {}
|
|
33
|
+
try:
|
|
34
|
+
owner = json.loads((path / "owner.json").read_text())
|
|
35
|
+
except (OSError, ValueError):
|
|
36
|
+
pass
|
|
37
|
+
ours = owner == {"pid": pid, "birth": birth}
|
|
38
|
+
if release:
|
|
39
|
+
if ours:
|
|
40
|
+
shutil.rmtree(path)
|
|
41
|
+
return ours
|
|
42
|
+
if path.exists():
|
|
43
|
+
owner_pid = owner.get("pid") if isinstance(owner, dict) else None
|
|
44
|
+
if isinstance(owner_pid, int) and owner_pid > 0:
|
|
45
|
+
if owner.get("birth") and process_birth(owner_pid) == owner["birth"]:
|
|
46
|
+
return False
|
|
47
|
+
elif time.time() - path.stat().st_mtime < 120:
|
|
48
|
+
return False
|
|
49
|
+
shutil.rmtree(path)
|
|
50
|
+
path.mkdir()
|
|
51
|
+
(path / "owner.json").write_text(json.dumps({"pid": pid, "birth": birth}))
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
sys.exit(0 if change_lock(Path(sys.argv[2]), int(sys.argv[3]), sys.argv[1] == "release") else 1)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Read-only usage transport with bounded retries and non-secret failure categories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from email.utils import parsedate_to_datetime
|
|
6
|
+
import errno
|
|
7
|
+
import json
|
|
8
|
+
import socket
|
|
9
|
+
import ssl
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
TRANSPORT_ERRORS = {
|
|
15
|
+
"network_unreachable", "dns_unavailable", "usage_timeout", "connection_reset",
|
|
16
|
+
"tls_error", "edge_challenge",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def failure_kind(error: Exception) -> str:
|
|
21
|
+
"""Classify the exception without retaining URLs, headers, bodies, or tokens."""
|
|
22
|
+
if isinstance(error, urllib.error.HTTPError):
|
|
23
|
+
if ((error.headers or {}).get("cf-mitigated") or "").lower() == "challenge":
|
|
24
|
+
return "edge_challenge"
|
|
25
|
+
return f"http_{error.code}" if error.code in (401, 403, 429) else "http_error"
|
|
26
|
+
reason = error.reason if isinstance(error, urllib.error.URLError) else error
|
|
27
|
+
if isinstance(reason, socket.gaierror):
|
|
28
|
+
return "dns_unavailable"
|
|
29
|
+
if isinstance(reason, (socket.timeout, TimeoutError)):
|
|
30
|
+
return "usage_timeout"
|
|
31
|
+
if isinstance(reason, ssl.SSLError):
|
|
32
|
+
return "tls_error"
|
|
33
|
+
if isinstance(reason, OSError):
|
|
34
|
+
if reason.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ENETDOWN):
|
|
35
|
+
return "network_unreachable"
|
|
36
|
+
if reason.errno in (errno.ECONNRESET, errno.ECONNREFUSED, errno.EPIPE):
|
|
37
|
+
return "connection_reset"
|
|
38
|
+
if isinstance(error, (ValueError, UnicodeError)):
|
|
39
|
+
return "invalid_usage"
|
|
40
|
+
return "usage_read_failed"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def retryable(error: Exception) -> bool:
|
|
44
|
+
if isinstance(error, urllib.error.HTTPError):
|
|
45
|
+
return error.code in (500, 502, 503, 504) and not (error.headers or {}).get("Retry-After")
|
|
46
|
+
return failure_kind(error) in TRANSPORT_ERRORS - {"tls_error", "edge_challenge"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fetch_usage(request, budget: float = 15):
|
|
50
|
+
"""Retry once within a monotonic budget; OS DNS/socket operations retain their own timeouts."""
|
|
51
|
+
deadline = time.monotonic() + budget
|
|
52
|
+
for attempt in range(2):
|
|
53
|
+
remaining = max(0.1, deadline - time.monotonic())
|
|
54
|
+
try:
|
|
55
|
+
with urllib.request.urlopen(request, timeout=min(10, remaining)) as response:
|
|
56
|
+
result = json.loads(response.read().decode())
|
|
57
|
+
if not isinstance(result, dict):
|
|
58
|
+
raise ValueError("invalid usage object")
|
|
59
|
+
return result
|
|
60
|
+
except Exception as error:
|
|
61
|
+
if isinstance(error, urllib.error.HTTPError):
|
|
62
|
+
error.close()
|
|
63
|
+
if attempt or not retryable(error) or time.monotonic() >= deadline:
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def retry_delay(error: Exception, previous: dict, now: float) -> int:
|
|
68
|
+
"""Honor Retry-After; otherwise back off transient failures without hammering an account."""
|
|
69
|
+
header = (error.headers or {}).get("Retry-After") if isinstance(error, urllib.error.HTTPError) else None
|
|
70
|
+
if header:
|
|
71
|
+
try:
|
|
72
|
+
delay = int(header)
|
|
73
|
+
except (TypeError, ValueError):
|
|
74
|
+
try:
|
|
75
|
+
delay = int(parsedate_to_datetime(header).timestamp() - now)
|
|
76
|
+
except (TypeError, ValueError, OverflowError):
|
|
77
|
+
delay = 0
|
|
78
|
+
if delay > 0:
|
|
79
|
+
return delay
|
|
80
|
+
try:
|
|
81
|
+
prior = int(previous.get("backoff", 60))
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
prior = 60
|
|
84
|
+
ceiling = 1800 if failure_kind(error) == "http_429" else 900
|
|
85
|
+
return min(ceiling, max(120, prior * 2))
|