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.
Files changed (67) hide show
  1. package/bin/codex +55 -11
  2. package/bin/codex-accounts +11 -4
  3. package/bin/multiacc-select +6 -1
  4. package/docs/CODEX.md +90 -0
  5. package/docs/UNIFIED_SELECTOR.md +4 -3
  6. package/docs/VERIFICATION.md +1 -1
  7. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  27. package/lib/autoresume.py +7 -2
  28. package/lib/autoresume_probe.py +3 -3
  29. package/lib/codex_credit_local_scope.py +35 -0
  30. package/lib/codex_credit_selection.py +166 -0
  31. package/lib/codex_credits.py +162 -0
  32. package/lib/report.py +6 -0
  33. package/lib/selector_credit_scope.py +55 -0
  34. package/lib/selector_credits.py +99 -0
  35. package/lib/selector_funding.py +111 -0
  36. package/lib/selector_policy.py +23 -14
  37. package/lib/selector_primitives.py +20 -2
  38. package/package.json +3 -2
  39. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  40. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  41. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  42. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  43. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  61. package/tests/test_codex_reset_credits.py +112 -0
  62. package/tests/test_codex_reset_credits_polling.py +94 -0
  63. package/tests/test_codex_reset_credits_report.py +85 -0
  64. package/tests/test_selector_credit_scope.py +118 -0
  65. package/tests/test_selector_credit_shim.py +139 -0
  66. package/tests/test_selector_credits.py +139 -0
  67. 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