claude-multiacc 2.0.50 → 2.0.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/bin/codex +55 -11
  2. package/bin/codex-accounts +34 -40
  3. package/bin/multiacc-select +6 -1
  4. package/docs/CODEX.md +118 -0
  5. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  6. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  28. package/lib/autoresume.py +7 -2
  29. package/lib/autoresume_probe.py +3 -3
  30. package/lib/codex_credit_controls.py +27 -0
  31. package/lib/codex_credit_local_scope.py +35 -0
  32. package/lib/codex_credit_selection.py +172 -0
  33. package/lib/codex_credits.py +158 -0
  34. package/lib/codex_limits_lock.py +56 -0
  35. package/lib/codex_usage_fetch.py +85 -0
  36. package/lib/report.py +6 -0
  37. package/lib/selector_credit_scope.py +85 -0
  38. package/lib/selector_credits.py +105 -0
  39. package/lib/selector_funding.py +113 -0
  40. package/lib/selector_policy.py +20 -2
  41. package/lib/selector_primitives.py +20 -2
  42. package/package.json +1 -1
  43. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  67. package/tests/test_codex_reset_credits.py +154 -0
  68. package/tests/test_codex_reset_credits_polling.py +151 -0
  69. package/tests/test_codex_reset_credits_report.py +85 -0
  70. package/tests/test_codex_reset_fetch.py +123 -0
  71. package/tests/test_selector_cached_credits.py +135 -0
  72. package/tests/test_selector_credit_scope.py +118 -0
  73. package/tests/test_selector_credit_shim.py +139 -0
  74. package/tests/test_selector_credits.py +140 -0
@@ -0,0 +1,35 @@
1
+ """Non-secret local actor identity for shared Codex wallet refusal receipts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from selector_credit_scope import actor_email, actor_key, actor_refusals
9
+
10
+
11
+ def pool_actor(directory: Path) -> str | None:
12
+ try:
13
+ document = json.loads((directory.parent / "accounts.json").read_text())
14
+ accounts = document.get("accounts") or []
15
+ except (OSError, ValueError, AttributeError):
16
+ return None
17
+ for account in accounts:
18
+ if isinstance(account, dict) and account.get("id") == directory.name:
19
+ return actor_email({"credit_actor_email": account.get("email")})
20
+ return None
21
+
22
+
23
+ def merged_local_refusals(directories, read_json, refusal_view):
24
+ candidates = []
25
+ for item in directories:
26
+ directory = Path(item)
27
+ candidates.append({"credits": read_json(directory / "limits.json").get("credits"),
28
+ "runner_id": str(directory.parent.resolve()), "account_id": directory.name,
29
+ "credit_actor_email": pool_actor(directory), **refusal_view(directory)})
30
+ return actor_refusals(candidates)
31
+
32
+
33
+ def local_refusal_key(directory, credits):
34
+ return actor_key({"credits": credits, "credit_actor_email": pool_actor(directory),
35
+ "runner_id": str(directory.parent.resolve()), "account_id": directory.name})
@@ -0,0 +1,172 @@
1
+ """Local Codex shim credit fallback and durable paid-refusal receipts.
2
+
3
+ Only reads telemetry already collected by codex-accounts; never calls a provider.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import sys
11
+ import time
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ from selector_credits import balance_value, normalize_credits, refusal_recovered
16
+ from selector_funding import credit_rank, wallet_snapshots
17
+ from selector_credit_scope import actor_observations, merge_actor, merge_wallet
18
+ from codex_credit_local_scope import pool_actor, merged_local_refusals, local_refusal_key
19
+ from selector_primitives import canonical_sha256, timestamp
20
+
21
+
22
+ def read_json(path):
23
+ try:
24
+ value = json.loads(path.read_text())
25
+ return value if isinstance(value, dict) else {}
26
+ except (OSError, ValueError):
27
+ return {}
28
+
29
+
30
+ def write_json(path, value):
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
33
+ with temporary.open("w") as target:
34
+ os.chmod(temporary, 0o600)
35
+ json.dump(value, target, separators=(",", ":"))
36
+ temporary.replace(path)
37
+
38
+
39
+ def marker_allows(directory, now):
40
+ try:
41
+ lines = (directory / ".limited").read_text().splitlines()
42
+ if float(lines[0]) <= now:
43
+ return True
44
+ fields = dict(item.split("=", 1) for item in lines[1].split() if "=" in item)
45
+ reason = fields.get("reason")
46
+ return reason in {"limits", "client-rate-limit"} or (
47
+ reason == "credit-refused" and bool(credit_refusal_view(directory)))
48
+ except FileNotFoundError:
49
+ return True
50
+ except (OSError, ValueError, IndexError):
51
+ return False
52
+
53
+
54
+ def candidate(directory, now, wallets, refusals, observations):
55
+ data = read_json(directory / "limits.json")
56
+ credits = normalize_credits(data.get("credits"), datetime.fromtimestamp(now, timezone.utc))
57
+ credits = merge_actor(credits, observations.get(local_refusal_key(directory, credits)))
58
+ credits = merge_wallet(credits, wallets.get(credits["account_id"]))
59
+ refusal = dict(credit_refusal_view(directory), **refusals.get(local_refusal_key(directory, credits), {}))
60
+ if not credits["available"] or not refusal_recovered(refusal, credits, datetime.fromtimestamp(now, timezone.utc)):
61
+ return None
62
+ if not marker_allows(directory, now):
63
+ return None
64
+ # A paid fallback needs evidence that the subscription is exhausted. At90-99%
65
+ # the ordinary reserve still takes precedence and its existing gate/band apply.
66
+ values = [data.get(key) for key in ("weekly_percent", "session_percent")]
67
+ exhausted = any(isinstance(v, (int, float)) and not isinstance(v, bool) and v >= 100 for v in values)
68
+ if not exhausted and credits["ordinary_usage_allowed"] is not False:
69
+ return None
70
+ return {"directory": str(directory), "credits": credits, "credit_active_holders": 0}
71
+
72
+
73
+ def active_holders(root, now, cleanup=False):
74
+ holders = {}
75
+ for record in root.glob("acct-*/.credit-sessions/*.json"):
76
+ data = read_json(record)
77
+ try:
78
+ pid = int(record.stem)
79
+ if pid <= 0 or now - float(data["launched_at"]) > 86400:
80
+ continue
81
+ os.kill(pid, 0)
82
+ except (KeyError, ValueError, OSError):
83
+ if cleanup:
84
+ record.unlink(missing_ok=True)
85
+ continue
86
+ account = data.get("account_id")
87
+ holders[account] = holders.get(account, 0) + 1
88
+ return holders
89
+
90
+
91
+ def pick(now, 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)
97
+ wallets = wallet_snapshots(snapshots, datetime.fromtimestamp(now, timezone.utc), 900)
98
+ refusals = merged_local_refusals(directories, read_json, credit_refusal_view)
99
+ rows = [row for directory in directories
100
+ if (row := candidate(Path(directory), now, wallets, refusals, observations))]
101
+ if not rows:
102
+ return ""
103
+ holders = active_holders(Path(directories[0]).parent, now)
104
+ for row in rows:
105
+ row["credit_active_holders"] = holders.get(row["credits"]["account_id"], 0)
106
+ best = min(credit_rank(row) for row in rows)
107
+ rows = [row for row in rows if credit_rank(row) == best]
108
+ root = Path(directories[0]).parent
109
+ try:
110
+ previous = (root / ".last-pick").read_text().strip()
111
+ except OSError:
112
+ previous = ""
113
+ peers = [row for row in rows if Path(row["directory"]).name != previous] or rows
114
+ return min(peers, key=lambda row: canonical_sha256(
115
+ [str(now), str(os.getppid()), row["credits"]["account_id"], row["directory"]]))["directory"]
116
+
117
+
118
+ def record_launch(directory, pid, now):
119
+ active_holders(directory.parent, now, cleanup=True)
120
+ credits = read_json(directory / "limits.json").get("credits") or {}
121
+ write_json(directory / ".credit-sessions" / f"{pid}.json", {
122
+ "account_id": credits.get("account_id"), "balance": credits.get("balance"), "launched_at": now,
123
+ "credit_actor_email": pool_actor(directory)})
124
+
125
+
126
+ def record_refusal(directory, pid, now):
127
+ launch = read_json(directory / ".credit-sessions" / f"{pid}.json")
128
+ current = (read_json(directory / "limits.json").get("credits") or {})
129
+ balance = launch.get("balance")
130
+ if not launch:
131
+ balance = current.get("balance")
132
+ write_json(directory / ".credit-refused.json", {
133
+ "credit_blocked_at": datetime.fromtimestamp(now, timezone.utc).isoformat(),
134
+ "credit_blocked_balance": balance, "limited_reason": "credits",
135
+ "account_id": launch.get("account_id") or current.get("account_id"),
136
+ "credit_actor_email": launch.get("credit_actor_email", pool_actor(directory))})
137
+ (directory / ".credit-sessions" / f"{pid}.json").unlink(missing_ok=True)
138
+
139
+
140
+ def credit_refusal_view(directory):
141
+ directory = Path(directory)
142
+ refusal = read_json(directory / ".credit-refused.json")
143
+ account = (read_json(directory / "limits.json").get("credits") or {}).get("account_id")
144
+ if not account or refusal.get("account_id") != account:
145
+ return {}
146
+ actor = pool_actor(directory)
147
+ if refusal.get("credit_actor_email") and refusal["credit_actor_email"] != actor:
148
+ return {}
149
+ try:
150
+ _, blocked_at = timestamp(refusal.get("credit_blocked_at"))
151
+ except ValueError:
152
+ blocked_at = None
153
+ return {"credit_blocked_at": blocked_at,
154
+ "credit_blocked_balance": balance_value(refusal.get("credit_blocked_balance")),
155
+ "limited_reason": "credits"}
156
+
157
+
158
+ def main(argv):
159
+ now = time.time()
160
+ if argv[0] == "pick":
161
+ print(pick(float(argv[1]), argv[2:]))
162
+ elif argv[0] == "record":
163
+ record_launch(Path(argv[1]), int(argv[2]), now)
164
+ elif argv[0] == "refuse":
165
+ record_refusal(Path(argv[1]), int(argv[2]), now)
166
+ else:
167
+ return 2
168
+ return 0
169
+
170
+
171
+ if __name__ == "__main__":
172
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,158 @@
1
+ """Spendable Codex credits from the existing usage read; never redeem or infer funds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from decimal import Decimal, InvalidOperation
7
+ import json
8
+ import os
9
+ import re
10
+ import time
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
+
15
+ _DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\Z")
16
+ _IDENTITY = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,199}\Z")
17
+ _ERRORS = {
18
+ "missing_credits", "invalid_credits", "account_mismatch", "unauthenticated",
19
+ "usage_read_failed", "invalid_usage", "http_401", "http_403", "http_429", "http_error",
20
+ "usage_reset_pending",
21
+ } | TRANSPORT_ERRORS
22
+
23
+
24
+ def _iso(value: float) -> str:
25
+ return datetime.fromtimestamp(value, timezone.utc).isoformat().replace("+00:00", "Z")
26
+
27
+
28
+ def _balance(value):
29
+ if value is None:
30
+ return None
31
+ if not isinstance(value, str) or len(value) > 128 or not _DECIMAL.fullmatch(value):
32
+ raise ValueError("invalid credit balance")
33
+ try:
34
+ number = Decimal(value)
35
+ except InvalidOperation as error:
36
+ raise ValueError("invalid credit balance") from error
37
+ result = format(number, "f")
38
+ return result.rstrip("0").rstrip(".") if "." in result else result
39
+
40
+
41
+ def _identity(value):
42
+ return value if isinstance(value, str) and _IDENTITY.fullmatch(value) else None
43
+
44
+
45
+ def _optional_bool(value):
46
+ return value if type(value) is bool else None
47
+
48
+
49
+ def snapshot_from_usage(usage: dict, account_id: str, now: float) -> dict:
50
+ """Bind the provider's wallet and admission facts to the authenticated account."""
51
+ if not isinstance(usage, dict):
52
+ raise ValueError("invalid_usage")
53
+ root = usage.get("rateLimits")
54
+ root = root if isinstance(root, dict) else usage
55
+ response_id = usage.get("account_id", usage.get("accountId"))
56
+ expected, reported = _identity(account_id), _identity(response_id)
57
+ if response_id is not None and not reported:
58
+ raise ValueError("account_mismatch")
59
+ if expected and reported and expected != reported:
60
+ raise ValueError("account_mismatch")
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")
67
+ try:
68
+ balance = _balance(raw.get("balance"))
69
+ except ValueError as error:
70
+ raise ValueError("invalid_credits") from error
71
+ return {
72
+ "has_credits": has_credits, "unlimited": raw["unlimited"], "balance": balance,
73
+ "fetched_at": _iso(now), "account_id": reported or expected,
74
+ **_controls(usage, root),
75
+ }
76
+
77
+
78
+ def credit_view(value, now: float | None = None, stale_after: int = 900) -> dict | None:
79
+ """Sanitize the stored snapshot, preserving its own timestamp and unknown values."""
80
+ if not isinstance(value, dict):
81
+ return None
82
+ if type(value.get("has_credits")) is not bool or type(value.get("unlimited")) is not bool:
83
+ return None
84
+ try:
85
+ stamp = datetime.fromisoformat(value["fetched_at"].replace("Z", "+00:00"))
86
+ if stamp.tzinfo is None or stamp.timestamp() <= 0:
87
+ return None
88
+ age = (time.time() if now is None else now) - stamp.timestamp()
89
+ balance = _balance(value.get("balance"))
90
+ except (KeyError, ValueError, TypeError, AttributeError, OverflowError):
91
+ return None
92
+ reached = value.get("limit_reached_type")
93
+ out = {
94
+ "has_credits": value["has_credits"], "unlimited": value["unlimited"], "balance": balance,
95
+ "fetched_at": _iso(stamp.timestamp()), "account_id": _identity(value.get("account_id")),
96
+ "ordinary_usage_allowed": _optional_bool(value.get("ordinary_usage_allowed")),
97
+ "spend_control_reached": _optional_bool(value.get("spend_control_reached")),
98
+ "limit_reached_type": _limit_type(reached),
99
+ "age_seconds": max(0, int(age)), "stale": age > stale_after or age < -30,
100
+ }
101
+ error = value.get("last_error")
102
+ if error:
103
+ out["last_error"] = error if isinstance(error, str) and error in _ERRORS else "usage_read_failed"
104
+ out["stale"] = True
105
+ stamp = value.get("last_error_at")
106
+ if isinstance(stamp, str) and len(stamp) <= 64:
107
+ try:
108
+ parsed = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
109
+ if parsed.tzinfo is not None and parsed.timestamp() > 0:
110
+ out["last_error_at"] = _iso(parsed.timestamp())
111
+ except (ValueError, OverflowError):
112
+ pass
113
+ return out
114
+
115
+
116
+ def failed_credits(previous, error: str, now: float) -> dict | None:
117
+ """Keep the last measured amount and its age when a telemetry read fails."""
118
+ out = credit_view(previous, now)
119
+ if out is not None:
120
+ if out.get("last_error") in ("account_mismatch", "unauthenticated"):
121
+ return out
122
+ out.update(last_error=error if error in _ERRORS else "usage_read_failed",
123
+ last_error_at=_iso(now), stale=True)
124
+ return out
125
+
126
+
127
+ def collect_credits(usage, account_id: str, now: float, previous=None) -> dict | None:
128
+ try:
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
133
+ except ValueError as error:
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
144
+
145
+
146
+ def record_read_failure(path: str, previous: dict, error: str, now: float) -> None:
147
+ """Persist a bounded non-secret failure while retaining earlier usage observations."""
148
+ out = dict(previous)
149
+ out.update(last_error=error if error in _ERRORS else "usage_read_failed", last_error_at=int(now))
150
+ credits = failed_credits(out.get("credits"), error, now)
151
+ if credits is not None:
152
+ out["credits"] = credits
153
+ else:
154
+ out.pop("credits", None)
155
+ temporary = path + ".tmp"
156
+ with open(temporary, "w", encoding="utf-8") as handle:
157
+ json.dump(out, handle, indent=1)
158
+ os.replace(temporary, path)
@@ -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))
package/lib/report.py CHANGED
@@ -49,6 +49,8 @@ import keychain # noqa: E402
49
49
  from audit import creds_doc_state, token_verified # noqa: E402
50
50
  from claude_client_error import org_access_verified_at # noqa: E402
51
51
  from claude_reset import marker_superseded, record_from # noqa: E402
52
+ from codex_credits import credit_view # noqa: E402
53
+ from codex_credit_selection import credit_refusal_view # noqa: E402
52
54
 
53
55
  SCHEMA = 'claude-multiacc/pool.v1'
54
56
 
@@ -262,6 +264,10 @@ def _usage(d, stale_after):
262
264
  out = {'fetched_at': _iso(fetched), 'source': lim.get('source'),
263
265
  'max_percent': lim.get('max_percent'), 'weekly_percent': lim.get('weekly_percent'),
264
266
  'session_percent': lim.get('session_percent'), 'buckets': buckets}
267
+ credits = credit_view(lim.get('credits'), stale_after=stale_after)
268
+ if credits is not None:
269
+ out['credits'] = credits
270
+ out.update(credit_refusal_view(d))
265
271
  reset_count = lim.get('reset_credits_available')
266
272
  reset_stamp = _iso(lim.get('reset_credits_fetched_at'))
267
273
  if type(reset_count) is int and 0 <= reset_count <= 1_000_000 and reset_stamp:
@@ -0,0 +1,85 @@
1
+ """Wallet finances are shared; actor permissions, authentication and refusals are not."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+
7
+ from selector_primitives import timestamp
8
+ from selector_credits import HARD_CREDIT_ERRORS, normalize_credits
9
+
10
+ FINANCIAL_FIELDS = ("has_credits", "unlimited", "balance", "account_id")
11
+
12
+
13
+ def actor_email(raw: dict) -> str | None:
14
+ value = raw.get("credit_actor_email")
15
+ return value.strip().lower() or None if isinstance(value, str) else None
16
+
17
+
18
+ def wallet_available(credits: dict) -> bool:
19
+ balance = credits.get("balance")
20
+ funds = credits["unlimited"] or (credits["has_credits"] and balance is not None and Decimal(balance) > 0)
21
+ return bool(credits["account_id"] and credits["fetched_at"]
22
+ and credits["last_error"] not in HARD_CREDIT_ERRORS and funds)
23
+
24
+
25
+ def merge_wallet(local: dict, wallet: dict | None) -> dict:
26
+ if wallet is None:
27
+ return local
28
+ merged = dict(local)
29
+ merged.update({key: wallet[key] for key in FINANCIAL_FIELDS})
30
+ merged["available"] = bool(local["fetched_at"] and local["last_error"] not in HARD_CREDIT_ERRORS
31
+ and not local["spend_control_reached"] and wallet["available"])
32
+ return merged
33
+
34
+
35
+ def actor_key(raw: dict) -> tuple:
36
+ credits = raw.get("credits") or {}
37
+ account, actor = credits.get("account_id"), actor_email(raw)
38
+ if actor:
39
+ return account, actor
40
+ return account, None, raw.get("runner_id"), raw.get("account_id")
41
+
42
+
43
+ def actor_refusals(candidates: list[dict]) -> dict:
44
+ refusals = {}
45
+ for raw in candidates:
46
+ if not raw.get("credit_blocked_at") or (raw.get("credits") or {}).get("last_error") == "account_mismatch":
47
+ continue
48
+ try:
49
+ _, blocked = timestamp(raw["credit_blocked_at"])
50
+ except ValueError:
51
+ continue
52
+ key = actor_key(raw)
53
+ previous = refusals.get(key)
54
+ if key[0] and (previous is None or blocked > previous["credit_blocked_at"]):
55
+ refusals[key] = {"credit_blocked_at": blocked,
56
+ "credit_blocked_balance": raw.get("credit_blocked_balance")}
57
+ return refusals
58
+
59
+
60
+ def actor_observations(candidates: list[dict], now, max_age: int) -> dict:
61
+ observations = {}
62
+ for raw in candidates:
63
+ credits = normalize_credits(raw.get("credits"), now, max_age)
64
+ if not credits["fetched_at"] or credits["last_error"] == "account_mismatch":
65
+ continue
66
+ key = actor_key(raw)
67
+ sticky = bool(credits["last_error"] and credits["spend_control_reached"])
68
+ rank = (sticky, credits["fetched_at"], credits["spend_control_reached"],
69
+ credits["ordinary_usage_allowed"] is False)
70
+ previous = observations.get(key)
71
+ if previous is None or rank > previous[0]:
72
+ observations[key] = rank, credits
73
+ return {key: value[1] for key, value in observations.items()}
74
+
75
+
76
+ def merge_actor(local: dict, observation: dict | None) -> dict:
77
+ # A partial response can confirm a new restriction while retaining old money's
78
+ # timestamp. A peer's newer money is not proof that this local restriction lifted.
79
+ if observation is None or (local["last_error"] and local["spend_control_reached"]):
80
+ return local
81
+ merged = dict(local)
82
+ for key in ("ordinary_usage_allowed", "spend_control_reached", "limit_reached_type"):
83
+ merged[key] = observation[key]
84
+ merged["available"] = wallet_available(local) and not merged["spend_control_reached"]
85
+ return merged