claude-multiacc 2.0.49 → 2.0.51
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 +11 -4
- package/bin/multiacc-select +6 -1
- package/docs/CODEX.md +90 -0
- package/docs/UNIFIED_SELECTOR.md +4 -3
- package/docs/VERIFICATION.md +1 -1
- 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_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_python.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_local_scope.py +35 -0
- package/lib/codex_credit_selection.py +166 -0
- package/lib/codex_credits.py +162 -0
- package/lib/report.py +6 -0
- package/lib/selector_credit_scope.py +55 -0
- package/lib/selector_credits.py +99 -0
- package/lib/selector_funding.py +111 -0
- package/lib/selector_policy.py +23 -14
- package/lib/selector_primitives.py +20 -2
- package/package.json +3 -2
- 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_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_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 +112 -0
- package/tests/test_codex_reset_credits_polling.py +94 -0
- package/tests/test_codex_reset_credits_report.py +85 -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 +139 -0
- package/tests/test_selector_spread.py +65 -0
|
@@ -0,0 +1,166 @@
|
|
|
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 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):
|
|
55
|
+
data = read_json(directory / "limits.json")
|
|
56
|
+
credits = normalize_credits(data.get("credits"), datetime.fromtimestamp(now, timezone.utc))
|
|
57
|
+
credits = merge_wallet(credits, wallets.get(credits["account_id"]))
|
|
58
|
+
refusal = dict(credit_refusal_view(directory), **refusals.get(local_refusal_key(directory, credits), {}))
|
|
59
|
+
if not credits["available"] or not refusal_recovered(refusal, credits, datetime.fromtimestamp(now, timezone.utc)):
|
|
60
|
+
return None
|
|
61
|
+
if not marker_allows(directory, now):
|
|
62
|
+
return None
|
|
63
|
+
# A paid fallback needs evidence that the subscription is exhausted. At90-99%
|
|
64
|
+
# the ordinary reserve still takes precedence and its existing gate/band apply.
|
|
65
|
+
values = [data.get(key) for key in ("weekly_percent", "session_percent")]
|
|
66
|
+
exhausted = any(isinstance(v, (int, float)) and not isinstance(v, bool) and v >= 100 for v in values)
|
|
67
|
+
if not exhausted and credits["ordinary_usage_allowed"] is not False:
|
|
68
|
+
return None
|
|
69
|
+
return {"directory": str(directory), "credits": credits, "credit_active_holders": 0}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def active_holders(root, now, cleanup=False):
|
|
73
|
+
holders = {}
|
|
74
|
+
for record in root.glob("acct-*/.credit-sessions/*.json"):
|
|
75
|
+
data = read_json(record)
|
|
76
|
+
try:
|
|
77
|
+
pid = int(record.stem)
|
|
78
|
+
if pid <= 0 or now - float(data["launched_at"]) > 86400:
|
|
79
|
+
continue
|
|
80
|
+
os.kill(pid, 0)
|
|
81
|
+
except (KeyError, ValueError, OSError):
|
|
82
|
+
if cleanup:
|
|
83
|
+
record.unlink(missing_ok=True)
|
|
84
|
+
continue
|
|
85
|
+
account = data.get("account_id")
|
|
86
|
+
holders[account] = holders.get(account, 0) + 1
|
|
87
|
+
return holders
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def pick(now, directories):
|
|
91
|
+
snapshots = [read_json(Path(directory) / "limits.json") for directory in directories]
|
|
92
|
+
wallets = wallet_snapshots(snapshots, datetime.fromtimestamp(now, timezone.utc), 900)
|
|
93
|
+
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))]
|
|
95
|
+
if not rows:
|
|
96
|
+
return ""
|
|
97
|
+
holders = active_holders(Path(directories[0]).parent, now)
|
|
98
|
+
for row in rows:
|
|
99
|
+
row["credit_active_holders"] = holders.get(row["credits"]["account_id"], 0)
|
|
100
|
+
best = min(credit_rank(row) for row in rows)
|
|
101
|
+
rows = [row for row in rows if credit_rank(row) == best]
|
|
102
|
+
root = Path(directories[0]).parent
|
|
103
|
+
try:
|
|
104
|
+
previous = (root / ".last-pick").read_text().strip()
|
|
105
|
+
except OSError:
|
|
106
|
+
previous = ""
|
|
107
|
+
peers = [row for row in rows if Path(row["directory"]).name != previous] or rows
|
|
108
|
+
return min(peers, key=lambda row: canonical_sha256(
|
|
109
|
+
[str(now), str(os.getppid()), row["credits"]["account_id"], row["directory"]]))["directory"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def record_launch(directory, pid, now):
|
|
113
|
+
active_holders(directory.parent, now, cleanup=True)
|
|
114
|
+
credits = read_json(directory / "limits.json").get("credits") or {}
|
|
115
|
+
write_json(directory / ".credit-sessions" / f"{pid}.json", {
|
|
116
|
+
"account_id": credits.get("account_id"), "balance": credits.get("balance"), "launched_at": now,
|
|
117
|
+
"credit_actor_email": pool_actor(directory)})
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def record_refusal(directory, pid, now):
|
|
121
|
+
launch = read_json(directory / ".credit-sessions" / f"{pid}.json")
|
|
122
|
+
current = (read_json(directory / "limits.json").get("credits") or {})
|
|
123
|
+
balance = launch.get("balance")
|
|
124
|
+
if not launch:
|
|
125
|
+
balance = current.get("balance")
|
|
126
|
+
write_json(directory / ".credit-refused.json", {
|
|
127
|
+
"credit_blocked_at": datetime.fromtimestamp(now, timezone.utc).isoformat(),
|
|
128
|
+
"credit_blocked_balance": balance, "limited_reason": "credits",
|
|
129
|
+
"account_id": launch.get("account_id") or current.get("account_id"),
|
|
130
|
+
"credit_actor_email": launch.get("credit_actor_email", pool_actor(directory))})
|
|
131
|
+
(directory / ".credit-sessions" / f"{pid}.json").unlink(missing_ok=True)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def credit_refusal_view(directory):
|
|
135
|
+
directory = Path(directory)
|
|
136
|
+
refusal = read_json(directory / ".credit-refused.json")
|
|
137
|
+
account = (read_json(directory / "limits.json").get("credits") or {}).get("account_id")
|
|
138
|
+
if not account or refusal.get("account_id") != account:
|
|
139
|
+
return {}
|
|
140
|
+
actor = pool_actor(directory)
|
|
141
|
+
if refusal.get("credit_actor_email") and refusal["credit_actor_email"] != actor:
|
|
142
|
+
return {}
|
|
143
|
+
try:
|
|
144
|
+
_, blocked_at = timestamp(refusal.get("credit_blocked_at"))
|
|
145
|
+
except ValueError:
|
|
146
|
+
blocked_at = None
|
|
147
|
+
return {"credit_blocked_at": blocked_at,
|
|
148
|
+
"credit_blocked_balance": balance_value(refusal.get("credit_blocked_balance")),
|
|
149
|
+
"limited_reason": "credits"}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main(argv):
|
|
153
|
+
now = time.time()
|
|
154
|
+
if argv[0] == "pick":
|
|
155
|
+
print(pick(float(argv[1]), argv[2:]))
|
|
156
|
+
elif argv[0] == "record":
|
|
157
|
+
record_launch(Path(argv[1]), int(argv[2]), now)
|
|
158
|
+
elif argv[0] == "refuse":
|
|
159
|
+
record_refusal(Path(argv[1]), int(argv[2]), now)
|
|
160
|
+
else:
|
|
161
|
+
return 2
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
_DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\Z")
|
|
13
|
+
_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
|
+
_ERRORS = {
|
|
19
|
+
"missing_credits", "invalid_credits", "account_mismatch", "unauthenticated",
|
|
20
|
+
"usage_read_failed", "invalid_usage", "http_401", "http_403", "http_429", "http_error",
|
|
21
|
+
"usage_reset_pending",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _iso(value: float) -> str:
|
|
26
|
+
return datetime.fromtimestamp(value, timezone.utc).isoformat().replace("+00:00", "Z")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _balance(value):
|
|
30
|
+
if value is None:
|
|
31
|
+
return None
|
|
32
|
+
if not isinstance(value, str) or len(value) > 128 or not _DECIMAL.fullmatch(value):
|
|
33
|
+
raise ValueError("invalid credit balance")
|
|
34
|
+
try:
|
|
35
|
+
number = Decimal(value)
|
|
36
|
+
except InvalidOperation as error:
|
|
37
|
+
raise ValueError("invalid credit balance") from error
|
|
38
|
+
result = format(number, "f")
|
|
39
|
+
return result.rstrip("0").rstrip(".") if "." in result else result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _identity(value):
|
|
43
|
+
return value if isinstance(value, str) and _IDENTITY.fullmatch(value) else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _optional_bool(value):
|
|
47
|
+
return value if type(value) is bool else None
|
|
48
|
+
|
|
49
|
+
|
|
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
|
+
def snapshot_from_usage(usage: dict, account_id: str, now: float) -> dict:
|
|
59
|
+
"""Bind the provider's wallet and admission facts to the authenticated account."""
|
|
60
|
+
if not isinstance(usage, dict):
|
|
61
|
+
raise ValueError("invalid_usage")
|
|
62
|
+
root = usage.get("rateLimits")
|
|
63
|
+
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
|
+
response_id = usage.get("account_id", usage.get("accountId"))
|
|
71
|
+
expected, reported = _identity(account_id), _identity(response_id)
|
|
72
|
+
if response_id is not None and not reported:
|
|
73
|
+
raise ValueError("account_mismatch")
|
|
74
|
+
if expected and reported and expected != reported:
|
|
75
|
+
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")))
|
|
83
|
+
try:
|
|
84
|
+
balance = _balance(raw.get("balance"))
|
|
85
|
+
except ValueError as error:
|
|
86
|
+
raise ValueError("invalid_credits") from error
|
|
87
|
+
return {
|
|
88
|
+
"has_credits": has_credits, "unlimited": raw["unlimited"], "balance": balance,
|
|
89
|
+
"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),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def credit_view(value, now: float | None = None, stale_after: int = 900) -> dict | None:
|
|
97
|
+
"""Sanitize the stored snapshot, preserving its own timestamp and unknown values."""
|
|
98
|
+
if not isinstance(value, dict):
|
|
99
|
+
return None
|
|
100
|
+
if type(value.get("has_credits")) is not bool or type(value.get("unlimited")) is not bool:
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
stamp = datetime.fromisoformat(value["fetched_at"].replace("Z", "+00:00"))
|
|
104
|
+
if stamp.tzinfo is None or stamp.timestamp() <= 0:
|
|
105
|
+
return None
|
|
106
|
+
age = (time.time() if now is None else now) - stamp.timestamp()
|
|
107
|
+
balance = _balance(value.get("balance"))
|
|
108
|
+
except (KeyError, ValueError, TypeError, AttributeError, OverflowError):
|
|
109
|
+
return None
|
|
110
|
+
reached = value.get("limit_reached_type")
|
|
111
|
+
out = {
|
|
112
|
+
"has_credits": value["has_credits"], "unlimited": value["unlimited"], "balance": balance,
|
|
113
|
+
"fetched_at": _iso(stamp.timestamp()), "account_id": _identity(value.get("account_id")),
|
|
114
|
+
"ordinary_usage_allowed": _optional_bool(value.get("ordinary_usage_allowed")),
|
|
115
|
+
"spend_control_reached": _optional_bool(value.get("spend_control_reached")),
|
|
116
|
+
"limit_reached_type": _limit_type(reached),
|
|
117
|
+
"age_seconds": max(0, int(age)), "stale": age > stale_after or age < -30,
|
|
118
|
+
}
|
|
119
|
+
error = value.get("last_error")
|
|
120
|
+
if error:
|
|
121
|
+
out["last_error"] = error if isinstance(error, str) and error in _ERRORS else "usage_read_failed"
|
|
122
|
+
out["stale"] = True
|
|
123
|
+
stamp = value.get("last_error_at")
|
|
124
|
+
if isinstance(stamp, str) and len(stamp) <= 64:
|
|
125
|
+
try:
|
|
126
|
+
parsed = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
|
127
|
+
if parsed.tzinfo is not None and parsed.timestamp() > 0:
|
|
128
|
+
out["last_error_at"] = _iso(parsed.timestamp())
|
|
129
|
+
except (ValueError, OverflowError):
|
|
130
|
+
pass
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def failed_credits(previous, error: str, now: float) -> dict | None:
|
|
135
|
+
"""A failed read invalidates spending evidence without inventing a zero balance."""
|
|
136
|
+
out = credit_view(previous, now)
|
|
137
|
+
if out is not None:
|
|
138
|
+
out.update(last_error=error if error in _ERRORS else "usage_read_failed",
|
|
139
|
+
last_error_at=_iso(now), stale=True)
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def collect_credits(usage, account_id: str, now: float, previous=None) -> dict | None:
|
|
144
|
+
try:
|
|
145
|
+
return snapshot_from_usage(usage, account_id, now)
|
|
146
|
+
except ValueError as error:
|
|
147
|
+
return failed_credits(previous, str(error), now)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def record_read_failure(path: str, previous: dict, error: str, now: float) -> None:
|
|
151
|
+
"""Persist a bounded non-secret failure while retaining earlier usage observations."""
|
|
152
|
+
out = dict(previous)
|
|
153
|
+
out.update(last_error=error if error in _ERRORS else "usage_read_failed", last_error_at=int(now))
|
|
154
|
+
credits = failed_credits(out.get("credits"), error, now)
|
|
155
|
+
if credits is not None:
|
|
156
|
+
out["credits"] = credits
|
|
157
|
+
else:
|
|
158
|
+
out.pop("credits", None)
|
|
159
|
+
temporary = path + ".tmp"
|
|
160
|
+
with open(temporary, "w", encoding="utf-8") as handle:
|
|
161
|
+
json.dump(out, handle, indent=1)
|
|
162
|
+
os.replace(temporary, path)
|
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,55 @@
|
|
|
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
|
+
|
|
9
|
+
FINANCIAL_FIELDS = ("has_credits", "unlimited", "balance", "account_id")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def actor_email(raw: dict) -> str | None:
|
|
13
|
+
value = raw.get("credit_actor_email")
|
|
14
|
+
return value.strip().lower() or None if isinstance(value, str) else None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def wallet_available(credits: dict) -> bool:
|
|
18
|
+
balance = credits.get("balance")
|
|
19
|
+
funds = credits["unlimited"] or (credits["has_credits"] and balance is not None and Decimal(balance) > 0)
|
|
20
|
+
return bool(credits["fresh"] and not credits["last_error"] and funds)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def merge_wallet(local: dict, wallet: dict | None) -> dict:
|
|
24
|
+
if wallet is None:
|
|
25
|
+
return local
|
|
26
|
+
merged = dict(local)
|
|
27
|
+
merged.update({key: wallet[key] for key in FINANCIAL_FIELDS})
|
|
28
|
+
merged["available"] = bool(local["fresh"] and not local["last_error"]
|
|
29
|
+
and not local["spend_control_reached"] and wallet["available"])
|
|
30
|
+
return merged
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def actor_key(raw: dict) -> tuple:
|
|
34
|
+
credits = raw.get("credits") or {}
|
|
35
|
+
account, actor = credits.get("account_id"), actor_email(raw)
|
|
36
|
+
if actor:
|
|
37
|
+
return account, actor
|
|
38
|
+
return account, None, raw.get("runner_id"), raw.get("account_id")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def actor_refusals(candidates: list[dict]) -> dict:
|
|
42
|
+
refusals = {}
|
|
43
|
+
for raw in candidates:
|
|
44
|
+
if not raw.get("credit_blocked_at") or (raw.get("credits") or {}).get("last_error"):
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
_, blocked = timestamp(raw["credit_blocked_at"])
|
|
48
|
+
except ValueError:
|
|
49
|
+
continue
|
|
50
|
+
key = actor_key(raw)
|
|
51
|
+
previous = refusals.get(key)
|
|
52
|
+
if key[0] and (previous is None or blocked > previous["credit_blocked_at"]):
|
|
53
|
+
refusals[key] = {"credit_blocked_at": blocked,
|
|
54
|
+
"credit_blocked_balance": raw.get("credit_blocked_balance")}
|
|
55
|
+
return refusals
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Pure, fail-closed credit telemetry normalization; no files or provider calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from decimal import Decimal, InvalidOperation
|
|
7
|
+
|
|
8
|
+
from selector_primitives import DECIMAL_RE, timestamp
|
|
9
|
+
|
|
10
|
+
CAPABILITY = "codex_credits_v1"
|
|
11
|
+
CREDIT_FIELDS = {
|
|
12
|
+
"has_credits", "unlimited", "balance", "fetched_at", "account_id", "ordinary_usage_allowed",
|
|
13
|
+
"spend_control_reached", "limit_reached_type", "last_error", "last_error_at", "stale", "age_seconds"}
|
|
14
|
+
OPTIONAL_CANDIDATE_FIELDS = {
|
|
15
|
+
"credits", "credit_capable", "credit_active_holders", "credit_blocked_at", "credit_blocked_balance",
|
|
16
|
+
"limited_reason", "credit_actor_email"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def balance_value(value: object) -> str | None:
|
|
20
|
+
"""Credits are arbitrary nonnegative decimals, never percentages or money."""
|
|
21
|
+
if not isinstance(value, str) or len(value) > 100 or not DECIMAL_RE.fullmatch(value):
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
number = Decimal(value)
|
|
25
|
+
if number < 0 or not number.is_finite():
|
|
26
|
+
return None
|
|
27
|
+
plain = format(number, "f")
|
|
28
|
+
return plain.rstrip("0").rstrip(".") if "." in plain else plain
|
|
29
|
+
except (InvalidOperation, ValueError):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def credit_candidate_error(raw: dict, field: str) -> str | None:
|
|
34
|
+
if "credit_capable" in raw and not isinstance(raw["credit_capable"], bool):
|
|
35
|
+
return f"{field}.credit_capable"
|
|
36
|
+
holders = raw.get("credit_active_holders", 0)
|
|
37
|
+
if isinstance(holders, bool) or not isinstance(holders, int) or not 0 <= holders <= 1_000_000:
|
|
38
|
+
return f"{field}.credit_active_holders"
|
|
39
|
+
for name in ("credit_blocked_at", "credit_blocked_balance", "limited_reason", "credit_actor_email"):
|
|
40
|
+
if raw.get(name) is not None and not isinstance(raw[name], str):
|
|
41
|
+
return f"{field}.{name}"
|
|
42
|
+
actor = raw.get("credit_actor_email")
|
|
43
|
+
if actor is not None and (len(actor) > 320 or any(c in actor for c in "\r\n\0")):
|
|
44
|
+
return f"{field}.credit_actor_email"
|
|
45
|
+
credits = raw.get("credits")
|
|
46
|
+
if credits is None:
|
|
47
|
+
return None
|
|
48
|
+
if not isinstance(credits, dict) or set(credits) - CREDIT_FIELDS:
|
|
49
|
+
return f"{field}.credits"
|
|
50
|
+
for name in ("balance", "fetched_at", "account_id", "limit_reached_type", "last_error", "last_error_at"):
|
|
51
|
+
if credits.get(name) is not None and not isinstance(credits[name], str):
|
|
52
|
+
return f"{field}.credits.{name}"
|
|
53
|
+
for name in ("has_credits", "unlimited", "ordinary_usage_allowed", "spend_control_reached", "stale"):
|
|
54
|
+
if credits.get(name) is not None and not isinstance(credits[name], bool):
|
|
55
|
+
return f"{field}.credits.{name}"
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def normalize_credits(raw: object, now: datetime, max_age: int = 900) -> dict:
|
|
60
|
+
raw = raw if isinstance(raw, dict) else {}
|
|
61
|
+
balance = balance_value(raw.get("balance"))
|
|
62
|
+
account = raw.get("account_id")
|
|
63
|
+
account = account.strip() if isinstance(account, str) and len(account) <= 256 else None
|
|
64
|
+
try:
|
|
65
|
+
observed, fetched_at = timestamp(raw.get("fetched_at"))
|
|
66
|
+
fresh = -30 <= (now - observed).total_seconds() <= max_age and raw.get("stale") is not True
|
|
67
|
+
except (ValueError, TypeError):
|
|
68
|
+
fetched_at, fresh = None, False
|
|
69
|
+
reached = raw.get("limit_reached_type")
|
|
70
|
+
reached = reached.lower() if isinstance(reached, str) else None
|
|
71
|
+
restricted = raw.get("spend_control_reached") is True or reached not in {
|
|
72
|
+
None, "", "rate_limit_reached", "usage_limit_reached", "weekly_limit_reached", "session_limit_reached"}
|
|
73
|
+
available = (raw.get("unlimited") is True or
|
|
74
|
+
(raw.get("has_credits") is True and balance is not None and Decimal(balance) > 0))
|
|
75
|
+
return {
|
|
76
|
+
"has_credits": raw.get("has_credits") is True, "unlimited": raw.get("unlimited") is True,
|
|
77
|
+
"balance": balance, "account_id": account or None, "fetched_at": fetched_at,
|
|
78
|
+
"ordinary_usage_allowed": raw.get("ordinary_usage_allowed")
|
|
79
|
+
if isinstance(raw.get("ordinary_usage_allowed"), bool) else None,
|
|
80
|
+
"spend_control_reached": restricted, "limit_reached_type": reached,
|
|
81
|
+
"last_error": bool(raw.get("last_error")), "fresh": fresh,
|
|
82
|
+
"available": bool(fresh and account and available and not restricted and not raw.get("last_error"))}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def refusal_recovered(raw: dict, credits: dict, now: datetime) -> bool:
|
|
86
|
+
"""Retry a funded wallet only after cooldown and a newer successful observation."""
|
|
87
|
+
blocked = raw.get("credit_blocked_at")
|
|
88
|
+
if not blocked:
|
|
89
|
+
return raw.get("limited_reason") != "credits"
|
|
90
|
+
try:
|
|
91
|
+
blocked_time, _ = timestamp(blocked)
|
|
92
|
+
fetched, _ = timestamp(credits.get("fetched_at"))
|
|
93
|
+
except (ValueError, TypeError):
|
|
94
|
+
return False
|
|
95
|
+
old = balance_value(raw.get("credit_blocked_balance"))
|
|
96
|
+
current = credits.get("balance")
|
|
97
|
+
increased = old is not None and current is not None and Decimal(current) > Decimal(old)
|
|
98
|
+
cooled = (now - blocked_time).total_seconds() >= 600
|
|
99
|
+
return bool(fetched > blocked_time and credits["available"] and (increased or cooled))
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Opted-in funding tiers preserve the legacy session gate and weekly band."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal, localcontext
|
|
6
|
+
|
|
7
|
+
from selector_credits import CAPABILITY, normalize_credits, refusal_recovered
|
|
8
|
+
from selector_credit_scope import actor_email, actor_key, actor_refusals, merge_wallet, wallet_available
|
|
9
|
+
from selector_primitives import timestamp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def wallet_snapshots(candidates: list[dict], now, max_age: int) -> dict:
|
|
13
|
+
wallets = {}
|
|
14
|
+
for raw in candidates:
|
|
15
|
+
credits = normalize_credits(raw.get("credits"), now, max_age)
|
|
16
|
+
key = credits["account_id"]
|
|
17
|
+
error = (raw.get("credits") or {}).get("last_error")
|
|
18
|
+
if not key or error in {"account_mismatch", "unauthenticated", "http_401", "http_403"}:
|
|
19
|
+
continue
|
|
20
|
+
credits["available"] = wallet_available(credits)
|
|
21
|
+
observed = credits["fetched_at"] or ""
|
|
22
|
+
if error:
|
|
23
|
+
try:
|
|
24
|
+
_, failed_at = timestamp(raw["credits"].get("last_error_at"))
|
|
25
|
+
observed = max(observed, failed_at)
|
|
26
|
+
except ValueError:
|
|
27
|
+
pass
|
|
28
|
+
credits["wallet_observed_at"] = observed
|
|
29
|
+
previous = wallets.get(key)
|
|
30
|
+
rank = (observed, not credits["available"])
|
|
31
|
+
if previous is None or rank > (previous["wallet_observed_at"], not previous["available"]):
|
|
32
|
+
wallets[key] = credits
|
|
33
|
+
return wallets
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def apply_funding(rows: list[dict], candidates: list[dict], now, request: dict) -> None:
|
|
37
|
+
max_age = request.get("credit_max_age_seconds", 900)
|
|
38
|
+
wallets = wallet_snapshots(candidates, now, max_age)
|
|
39
|
+
refusals = actor_refusals(candidates)
|
|
40
|
+
holders = {}
|
|
41
|
+
for raw in candidates:
|
|
42
|
+
account = (raw.get("credits") or {}).get("account_id")
|
|
43
|
+
holders[account] = max(holders.get(account, 0), raw.get("credit_active_holders", 0))
|
|
44
|
+
for row, raw in zip(rows, candidates):
|
|
45
|
+
credits = normalize_credits(raw.get("credits"), now, max_age)
|
|
46
|
+
credits = merge_wallet(credits, wallets.get(credits["account_id"]))
|
|
47
|
+
receipt = dict(raw, **refusals.get(actor_key(raw), {}))
|
|
48
|
+
known = row["quota_known"]
|
|
49
|
+
measured = [Decimal(row[key]) for key in ("weekly_pct", "session_pct") if row[key] is not None]
|
|
50
|
+
peak = max(measured) if measured else None
|
|
51
|
+
exhausted = peak is not None and peak >= 100
|
|
52
|
+
exhausted |= row["engine"] == "codex" and credits["ordinary_usage_allowed"] is False
|
|
53
|
+
reserve = peak is not None and 90 <= peak < 100
|
|
54
|
+
tier = "credits" if exhausted else "reserve" if reserve else "included"
|
|
55
|
+
recovered = refusal_recovered(receipt, credits, now)
|
|
56
|
+
reason = raw.get("limited_reason")
|
|
57
|
+
usage_park = reason == "usage" or (reason == "credits" and recovered)
|
|
58
|
+
park = row["exclusion_code"] in {"status_not_active", "account_limited"}
|
|
59
|
+
safe = row["status"] in {"active", "limited"} and (not park or usage_park)
|
|
60
|
+
paid = (row["engine"] == "codex" and raw.get("credit_capable") is True
|
|
61
|
+
and credits["available"] and recovered and safe)
|
|
62
|
+
# Retry normalization with just usage parks removed; independent auth, stale
|
|
63
|
+
# heartbeat, incapable provider and active reservations retain their exclusions.
|
|
64
|
+
if park and safe and ((tier == "credits" and paid) or (tier == "reserve" and known)):
|
|
65
|
+
from selector_policy import normalize_candidate
|
|
66
|
+
unparked = dict(raw, status="active", limited_until=None)
|
|
67
|
+
refreshed = normalize_candidate(unparked, now, row["input_ordinal"])
|
|
68
|
+
row["eligible"], row["exclusion_code"] = refreshed["eligible"], refreshed["exclusion_code"]
|
|
69
|
+
if row["eligible"] and tier == "credits" and not paid:
|
|
70
|
+
row["eligible"], row["exclusion_code"] = False, "credits_unavailable"
|
|
71
|
+
required = request.get("required_funding_tier")
|
|
72
|
+
if row["eligible"] and required and required != tier:
|
|
73
|
+
row["eligible"], row["exclusion_code"] = False, "funding_tier_excluded"
|
|
74
|
+
row.update(funding_tier=tier, credits=credits, credit_capable=raw.get("credit_capable") is True,
|
|
75
|
+
credit_active_holders=holders.get(credits["account_id"], 0),
|
|
76
|
+
credit_blocked_at=receipt.get("credit_blocked_at"),
|
|
77
|
+
credit_blocked_balance=receipt.get("credit_blocked_balance"), limited_reason=reason)
|
|
78
|
+
if "credit_actor_email" in raw:
|
|
79
|
+
row["credit_actor_email"] = actor_email(raw)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def best_funding_tier(rows: list[dict]) -> list[dict]:
|
|
83
|
+
order = {"included": 0, "reserve": 1, "credits": 2}
|
|
84
|
+
best = min(order[row["funding_tier"]] for row in rows)
|
|
85
|
+
return [row for row in rows if order[row["funding_tier"]] == best]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def credit_rank(row: dict) -> tuple:
|
|
89
|
+
credits, holders = row["credits"], row["credit_active_holders"]
|
|
90
|
+
with localcontext() as context:
|
|
91
|
+
context.prec = 110
|
|
92
|
+
share = Decimal(credits["balance"] or "0") / (holders + 1)
|
|
93
|
+
return (not credits["unlimited"], holders if credits["unlimited"] else -share)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def funding_proof(request: dict, winner: dict) -> dict:
|
|
97
|
+
return {"credit_capability": CAPABILITY, "credit_fallback": True,
|
|
98
|
+
"required_funding_tier": request.get("required_funding_tier"),
|
|
99
|
+
"credit_max_age_seconds": request.get("credit_max_age_seconds", 900),
|
|
100
|
+
"funding_tier": winner["funding_tier"]}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def funding_score(winner: dict) -> dict:
|
|
104
|
+
score = {"funding_tier": winner["funding_tier"],
|
|
105
|
+
"credit_balance": winner["credits"]["balance"],
|
|
106
|
+
"credit_account_id": winner["credits"]["account_id"],
|
|
107
|
+
"credit_active_holders": winner["credit_active_holders"],
|
|
108
|
+
"credit_unlimited": winner["credits"]["unlimited"]}
|
|
109
|
+
if "credit_actor_email" in winner:
|
|
110
|
+
score["credit_actor_email"] = winner["credit_actor_email"]
|
|
111
|
+
return score
|