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.
Files changed (66) hide show
  1. package/bin/codex +1 -1
  2. package/bin/codex-accounts +28 -41
  3. package/docs/CODEX.md +43 -15
  4. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  5. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  6. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_credit_controls.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_limits_lock.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  27. package/lib/codex_credit_controls.py +27 -0
  28. package/lib/codex_credit_selection.py +10 -4
  29. package/lib/codex_credits.py +28 -32
  30. package/lib/codex_limits_lock.py +56 -0
  31. package/lib/codex_usage_fetch.py +85 -0
  32. package/lib/selector_credit_scope.py +33 -3
  33. package/lib/selector_credits.py +10 -4
  34. package/lib/selector_funding.py +16 -14
  35. package/package.json +1 -1
  36. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  37. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  38. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  39. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  40. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  41. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  42. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  43. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/test_codex_reset_fetch.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_cached_credits.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  60. package/tests/test_codex_reset_credits.py +42 -0
  61. package/tests/test_codex_reset_credits_polling.py +58 -1
  62. package/tests/test_codex_reset_fetch.py +123 -0
  63. package/tests/test_selector_cached_credits.py +135 -0
  64. package/tests/test_selector_credit_scope.py +5 -5
  65. package/tests/test_selector_credit_shim.py +3 -3
  66. 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 stale/unknown wallets, spend controls and unresolved paid refusals.
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
@@ -1075,6 +1075,12 @@ cmd_relogin() {
1075
1075
  [ -z "$failed" ]
1076
1076
  }
1077
1077
 
1078
+ codex_limits_lock_release() {
1079
+ [ -n "${LIMITS_LOCK:-}" ] && "$PYBIN" "$LIB_DIR/codex_limits_lock.py" release "$LIMITS_LOCK" "$$"
1080
+ LIMITS_LOCK=""
1081
+ return 0
1082
+ }
1083
+
1078
1084
  cmd_limits() {
1079
1085
  require_manifest
1080
1086
  local quiet=0 force=0 json=0
@@ -1088,23 +1094,14 @@ cmd_limits() {
1088
1094
  done
1089
1095
  local lock="$ACC_ROOT/.locks/limits"
1090
1096
  mkdir -p "$ACC_ROOT/.locks"
1091
- if ! mkdir "$lock" 2>/dev/null; then
1092
- local age=$(( $(epoch_now) - $(file_mtime "$lock") ))
1093
- if [ "$age" -lt 120 ]; then
1094
- # A --json caller still gets the document (built from the state on disk) —
1095
- # a machine-readable verb must never answer a concurrent run with silence.
1096
- if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
1097
- [ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
1098
- return 0
1099
- fi
1100
- rm -rf "$lock"
1101
- if ! mkdir "$lock" 2>/dev/null; then
1102
- if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
1103
- return 0
1104
- fi
1097
+ if ! "$PYBIN" "$LIB_DIR/codex_limits_lock.py" acquire "$lock" "$$"; then
1098
+ # JSON callers still get the cached document while a live refresh owns the lock.
1099
+ if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
1100
+ [ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
1101
+ return 0
1105
1102
  fi
1106
1103
  LIMITS_LOCK="$lock"
1107
- trap limits_lock_release EXIT
1104
+ trap codex_limits_lock_release EXIT
1108
1105
  rotate_log limits.log
1109
1106
  # The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
1110
1107
  # never loosen it, or an account could sit at 95% and still be selected.
@@ -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
- now = time.time()
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
- say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
1419
+ reason = '429' if prev.get('last_error') in (None, 'http_429') else 'usage read failure'
1420
+ say(f'{aid}: backing off after {reason} ({int(retry_at - now)}s left); limits left as-is')
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', now)
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
- resp = urllib.request.urlopen(req, timeout=15)
1468
- data = json.loads(resp.read().decode())
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
- say(f'{aid}: usage fetch failed ({e}); failing open')
1488
- record_read_failure(lpath, prev, 'usage_read_failed', now)
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', now)
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
- limits_lock_release
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. An account
114
- with no previous wallet stays unknown after a failed read. Reports never expose raw
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, so newly refilled included quota is used before paid fallback.
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 network request, inference, purchase, redemption or billing-mode
120
- change. Earned `usage.reset_credits_available` remain separate: they reset included
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. Zero funds, missing observations and
131
- provider spending restrictions still deny paid work. Included quota can recover independently.
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); observations more than 30 seconds in the future cannot authorize spending.
145
- Optional candidate fields are:
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
- 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.
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
@@ -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") for directory in directories]
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 if (row := candidate(Path(directory), now, wallets, refusals))]
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)
@@ -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
- limit = root.get("rate_limit")
77
- limit = limit if isinstance(limit, dict) else {}
78
- ordinary = usage.get("ordinary_usage_allowed", usage.get("ordinaryUsageAllowed", limit.get("allowed")))
79
- reached = root.get("rate_limit_reached_type", root.get("rateLimitReachedType", limit.get("limit_reached_type")))
80
- spend = root.get("spend_control")
81
- spend = spend if isinstance(spend, dict) else {}
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
- "ordinary_usage_allowed": _optional_bool(ordinary),
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
- """A failed read invalidates spending evidence without inventing a zero balance."""
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
- return snapshot_from_usage(usage, account_id, now)
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
- return failed_credits(previous, str(error), now)
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))