claude-multiacc 2.0.50 → 2.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/codex +55 -11
- package/bin/codex-accounts +34 -40
- package/bin/multiacc-select +6 -1
- package/docs/CODEX.md +118 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/autoresume.py +7 -2
- package/lib/autoresume_probe.py +3 -3
- package/lib/codex_credit_controls.py +27 -0
- package/lib/codex_credit_local_scope.py +35 -0
- package/lib/codex_credit_selection.py +172 -0
- package/lib/codex_credits.py +158 -0
- package/lib/codex_limits_lock.py +56 -0
- package/lib/codex_usage_fetch.py +85 -0
- package/lib/report.py +6 -0
- package/lib/selector_credit_scope.py +85 -0
- package/lib/selector_credits.py +105 -0
- package/lib/selector_funding.py +113 -0
- package/lib/selector_policy.py +20 -2
- package/lib/selector_primitives.py +20 -2
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/test_codex_reset_credits.py +154 -0
- package/tests/test_codex_reset_credits_polling.py +151 -0
- package/tests/test_codex_reset_credits_report.py +85 -0
- package/tests/test_codex_reset_fetch.py +123 -0
- package/tests/test_selector_cached_credits.py +135 -0
- package/tests/test_selector_credit_scope.py +118 -0
- package/tests/test_selector_credit_shim.py +139 -0
- package/tests/test_selector_credits.py +140 -0
|
@@ -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
|