claude-multiacc 2.0.50 → 2.0.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/bin/codex +55 -11
  2. package/bin/codex-accounts +34 -40
  3. package/bin/multiacc-select +6 -1
  4. package/docs/CODEX.md +118 -0
  5. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  6. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  28. package/lib/autoresume.py +7 -2
  29. package/lib/autoresume_probe.py +3 -3
  30. package/lib/codex_credit_controls.py +27 -0
  31. package/lib/codex_credit_local_scope.py +35 -0
  32. package/lib/codex_credit_selection.py +172 -0
  33. package/lib/codex_credits.py +158 -0
  34. package/lib/codex_limits_lock.py +56 -0
  35. package/lib/codex_usage_fetch.py +85 -0
  36. package/lib/report.py +6 -0
  37. package/lib/selector_credit_scope.py +85 -0
  38. package/lib/selector_credits.py +105 -0
  39. package/lib/selector_funding.py +113 -0
  40. package/lib/selector_policy.py +20 -2
  41. package/lib/selector_primitives.py +20 -2
  42. package/package.json +1 -1
  43. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  67. package/tests/test_codex_reset_credits.py +154 -0
  68. package/tests/test_codex_reset_credits_polling.py +151 -0
  69. package/tests/test_codex_reset_credits_report.py +85 -0
  70. package/tests/test_codex_reset_fetch.py +123 -0
  71. package/tests/test_selector_cached_credits.py +135 -0
  72. package/tests/test_selector_credit_scope.py +118 -0
  73. package/tests/test_selector_credit_shim.py +139 -0
  74. package/tests/test_selector_credits.py +140 -0
@@ -0,0 +1,105 @@
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
+ HARD_CREDIT_ERRORS = {"account_mismatch", "unauthenticated", "refusal_read_failed"}
12
+ CREDIT_FIELDS = {
13
+ "has_credits", "unlimited", "balance", "fetched_at", "account_id", "ordinary_usage_allowed",
14
+ "spend_control_reached", "limit_reached_type", "last_error", "last_error_at", "stale", "age_seconds"}
15
+ OPTIONAL_CANDIDATE_FIELDS = {
16
+ "credits", "credit_capable", "credit_active_holders", "credit_blocked_at", "credit_blocked_balance",
17
+ "limited_reason", "credit_actor_email"}
18
+
19
+
20
+ def balance_value(value: object) -> str | None:
21
+ """Credits are arbitrary nonnegative decimals, never percentages or money."""
22
+ if not isinstance(value, str) or len(value) > 100 or not DECIMAL_RE.fullmatch(value):
23
+ return None
24
+ try:
25
+ number = Decimal(value)
26
+ if number < 0 or not number.is_finite():
27
+ return None
28
+ plain = format(number, "f")
29
+ return plain.rstrip("0").rstrip(".") if "." in plain else plain
30
+ except (InvalidOperation, ValueError):
31
+ return None
32
+
33
+
34
+ def credit_candidate_error(raw: dict, field: str) -> str | None:
35
+ if "credit_capable" in raw and not isinstance(raw["credit_capable"], bool):
36
+ return f"{field}.credit_capable"
37
+ holders = raw.get("credit_active_holders", 0)
38
+ if isinstance(holders, bool) or not isinstance(holders, int) or not 0 <= holders <= 1_000_000:
39
+ return f"{field}.credit_active_holders"
40
+ for name in ("credit_blocked_at", "credit_blocked_balance", "limited_reason", "credit_actor_email"):
41
+ if raw.get(name) is not None and not isinstance(raw[name], str):
42
+ return f"{field}.{name}"
43
+ actor = raw.get("credit_actor_email")
44
+ if actor is not None and (len(actor) > 320 or any(c in actor for c in "\r\n\0")):
45
+ return f"{field}.credit_actor_email"
46
+ credits = raw.get("credits")
47
+ if credits is None:
48
+ return None
49
+ if not isinstance(credits, dict) or set(credits) - CREDIT_FIELDS:
50
+ return f"{field}.credits"
51
+ for name in ("balance", "fetched_at", "account_id", "limit_reached_type", "last_error", "last_error_at"):
52
+ if credits.get(name) is not None and not isinstance(credits[name], str):
53
+ return f"{field}.credits.{name}"
54
+ for name in ("has_credits", "unlimited", "ordinary_usage_allowed", "spend_control_reached", "stale"):
55
+ if credits.get(name) is not None and not isinstance(credits[name], bool):
56
+ return f"{field}.credits.{name}"
57
+ return None
58
+
59
+
60
+ def normalize_credits(raw: object, now: datetime, max_age: int = 900) -> dict:
61
+ raw = raw if isinstance(raw, dict) else {}
62
+ balance = balance_value(raw.get("balance"))
63
+ account = raw.get("account_id")
64
+ account = account.strip() if isinstance(account, str) and len(account) <= 256 else None
65
+ try:
66
+ observed, fetched_at = timestamp(raw.get("fetched_at"))
67
+ age = (now - observed).total_seconds()
68
+ fresh = -30 <= age <= max_age and raw.get("stale") is not True
69
+ if age < -30:
70
+ fetched_at = None
71
+ except (ValueError, TypeError):
72
+ fetched_at, fresh = None, False
73
+ reached = raw.get("limit_reached_type")
74
+ reached = reached.lower() if isinstance(reached, str) else None
75
+ restricted = raw.get("spend_control_reached") is True or reached not in {
76
+ None, "", "rate_limit_reached", "usage_limit_reached", "weekly_limit_reached", "session_limit_reached"}
77
+ available = (raw.get("unlimited") is True or
78
+ (raw.get("has_credits") is True and balance is not None and Decimal(balance) > 0))
79
+ return {
80
+ "has_credits": raw.get("has_credits") is True, "unlimited": raw.get("unlimited") is True,
81
+ "balance": balance, "account_id": account or None, "fetched_at": fetched_at,
82
+ "ordinary_usage_allowed": raw.get("ordinary_usage_allowed")
83
+ if isinstance(raw.get("ordinary_usage_allowed"), bool) else None,
84
+ "spend_control_reached": restricted, "limit_reached_type": reached,
85
+ "last_error": raw.get("last_error") or False, "fresh": fresh,
86
+ "available": bool(fetched_at and account and available and not restricted
87
+ and raw.get("last_error") not in HARD_CREDIT_ERRORS)}
88
+
89
+
90
+ def refusal_recovered(raw: dict, credits: dict, now: datetime) -> bool:
91
+ """Retry a funded wallet only after cooldown and a newer successful observation."""
92
+ blocked = raw.get("credit_blocked_at")
93
+ if not blocked:
94
+ return raw.get("limited_reason") != "credits"
95
+ try:
96
+ blocked_time, _ = timestamp(blocked)
97
+ fetched, _ = timestamp(credits.get("fetched_at"))
98
+ except (ValueError, TypeError):
99
+ return False
100
+ old = balance_value(raw.get("credit_blocked_balance"))
101
+ current = credits.get("balance")
102
+ increased = old is not None and current is not None and Decimal(current) > Decimal(old)
103
+ cooled = (now - blocked_time).total_seconds() >= 600
104
+ return bool(fetched > blocked_time and credits["available"] and credits["fresh"]
105
+ and not credits["last_error"] and (increased or cooled))
@@ -0,0 +1,113 @@
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 (
9
+ actor_email, actor_key, actor_observations, actor_refusals, merge_actor, merge_wallet, wallet_available,
10
+ )
11
+
12
+
13
+ def wallet_snapshots(candidates: list[dict], now, max_age: int) -> dict:
14
+ wallets = {}
15
+ for raw in candidates:
16
+ credits = normalize_credits(raw.get("credits"), now, max_age)
17
+ key = credits["account_id"]
18
+ if not key:
19
+ continue
20
+ # The snapshot predates a failed refresh: its confirmed money remains true
21
+ # even when this slot's current credentials no longer authenticate.
22
+ credits["available"] = wallet_available(dict(credits, last_error=False))
23
+ observed = credits["fetched_at"] or ""
24
+ confirmed = bool(observed) and (credits["unlimited"]
25
+ or (raw.get("credits") or {}).get("has_credits") is False
26
+ or (credits["has_credits"] and credits["balance"] is not None))
27
+ credits["wallet_observed_at"], credits["wallet_confirmed"] = observed, confirmed
28
+ previous = wallets.get(key)
29
+ rank = (confirmed, observed, not credits["available"])
30
+ if previous is None or rank > (previous["wallet_confirmed"], previous["wallet_observed_at"],
31
+ 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
+ observations = actor_observations(candidates, now, max_age)
41
+ holders = {}
42
+ for raw in candidates:
43
+ account = (raw.get("credits") or {}).get("account_id")
44
+ holders[account] = max(holders.get(account, 0), raw.get("credit_active_holders", 0))
45
+ for row, raw in zip(rows, candidates):
46
+ credits = normalize_credits(raw.get("credits"), now, max_age)
47
+ credits = merge_actor(credits, observations.get(actor_key(raw)))
48
+ credits = merge_wallet(credits, wallets.get(credits["account_id"]))
49
+ receipt = dict(raw, **refusals.get(actor_key(raw), {}))
50
+ known = row["quota_known"]
51
+ measured = [Decimal(row[key]) for key in ("weekly_pct", "session_pct") if row[key] is not None]
52
+ peak = max(measured) if measured else None
53
+ exhausted = peak is not None and peak >= 100
54
+ exhausted |= row["engine"] == "codex" and credits["ordinary_usage_allowed"] is False
55
+ reserve = peak is not None and 90 <= peak < 100
56
+ tier = "credits" if exhausted else "reserve" if reserve else "included"
57
+ recovered = refusal_recovered(receipt, credits, now)
58
+ reason = raw.get("limited_reason")
59
+ usage_park = reason == "usage" or (reason == "credits" and recovered)
60
+ park = row["exclusion_code"] in {"status_not_active", "account_limited"}
61
+ safe = row["status"] in {"active", "limited"} and (not park or usage_park)
62
+ paid = (row["engine"] == "codex" and raw.get("credit_capable") is True
63
+ and credits["available"] and recovered and safe)
64
+ # Retry normalization with just usage parks removed; independent auth, stale
65
+ # heartbeat, incapable provider and active reservations retain their exclusions.
66
+ if park and safe and ((tier == "credits" and paid) or (tier == "reserve" and known)):
67
+ from selector_policy import normalize_candidate
68
+ unparked = dict(raw, status="active", limited_until=None)
69
+ refreshed = normalize_candidate(unparked, now, row["input_ordinal"])
70
+ row["eligible"], row["exclusion_code"] = refreshed["eligible"], refreshed["exclusion_code"]
71
+ if row["eligible"] and tier == "credits" and not paid:
72
+ row["eligible"], row["exclusion_code"] = False, "credits_unavailable"
73
+ required = request.get("required_funding_tier")
74
+ if row["eligible"] and required and required != tier:
75
+ row["eligible"], row["exclusion_code"] = False, "funding_tier_excluded"
76
+ row.update(funding_tier=tier, credits=credits, credit_capable=raw.get("credit_capable") is True,
77
+ credit_active_holders=holders.get(credits["account_id"], 0),
78
+ credit_blocked_at=receipt.get("credit_blocked_at"),
79
+ credit_blocked_balance=receipt.get("credit_blocked_balance"), limited_reason=reason)
80
+ if "credit_actor_email" in raw:
81
+ row["credit_actor_email"] = actor_email(raw)
82
+
83
+
84
+ def best_funding_tier(rows: list[dict]) -> list[dict]:
85
+ order = {"included": 0, "reserve": 1, "credits": 2}
86
+ best = min(order[row["funding_tier"]] for row in rows)
87
+ return [row for row in rows if order[row["funding_tier"]] == best]
88
+
89
+
90
+ def credit_rank(row: dict) -> tuple:
91
+ credits, holders = row["credits"], row["credit_active_holders"]
92
+ with localcontext() as context:
93
+ context.prec = 110
94
+ share = Decimal(credits["balance"] or "0") / (holders + 1)
95
+ return (not credits["unlimited"], holders if credits["unlimited"] else -share)
96
+
97
+
98
+ def funding_proof(request: dict, winner: dict) -> dict:
99
+ return {"credit_capability": CAPABILITY, "credit_fallback": True,
100
+ "required_funding_tier": request.get("required_funding_tier"),
101
+ "credit_max_age_seconds": request.get("credit_max_age_seconds", 900),
102
+ "funding_tier": winner["funding_tier"]}
103
+
104
+
105
+ def funding_score(winner: dict) -> dict:
106
+ score = {"funding_tier": winner["funding_tier"],
107
+ "credit_balance": winner["credits"]["balance"],
108
+ "credit_account_id": winner["credits"]["account_id"],
109
+ "credit_active_holders": winner["credit_active_holders"],
110
+ "credit_unlimited": winner["credits"]["unlimited"]}
111
+ if "credit_actor_email" in winner:
112
+ score["credit_actor_email"] = winner["credit_actor_email"]
113
+ return score
@@ -5,6 +5,9 @@ from __future__ import annotations
5
5
  from datetime import datetime
6
6
  from decimal import Decimal, localcontext
7
7
 
8
+ from selector_funding import (
9
+ apply_funding, best_funding_tier, credit_rank, funding_proof, funding_score,
10
+ )
8
11
  from selector_primitives import (
9
12
  DEFAULT_HEADROOM_BAND,
10
13
  DEFAULT_SESSION_GATE,
@@ -207,25 +210,35 @@ def select(request: object) -> dict:
207
210
  duplicate = next((items for items in identities.values() if len(items) > 1), None)
208
211
  if duplicate:
209
212
  return _error("duplicate_candidate_identity", {"input_ordinals": duplicate})
213
+ credit_mode = request.get("credit_fallback") is True
214
+ if credit_mode:
215
+ apply_funding(rows, request["candidates"], now, request)
210
216
  producer, alternatives = _apply_policy(request, rows)
211
217
  eligible = [row for row in rows if row["eligible"]]
212
218
  if not eligible:
213
219
  return _error("no_candidate", {"eligible_count": 0})
220
+ funded = best_funding_tier(eligible) if credit_mode else eligible
214
221
  band, band_text = decimal_value(request.get("headroom_band", DEFAULT_HEADROOM_BAND))
215
222
  gate, gate_text = decimal_value(request.get("session_gate", DEFAULT_SESSION_GATE))
216
223
  # Two cuts, in this order (operator's 2026-09-03 decision): the session gate says
217
224
  # WHO may be considered, the weekly band says which of those count as equally
218
225
  # good. "healthy or known" is the gate stepping aside when nobody clears it.
219
- known = [row for row in eligible if row["quota_known"]]
226
+ known = [row for row in funded if row["quota_known"]]
220
227
  healthy = _session_gate(known, gate)
221
228
  ranked = healthy or known
222
229
  floor = _band_floor(ranked, band) if band is not None else None
223
230
  if floor is None:
224
231
  # No usable quota anywhere: every eligible row is an unknown peer.
225
232
  # Keep LRU and the request spread instead of preferring a fixed identity.
226
- banded = eligible
233
+ banded = funded
227
234
  else:
228
235
  banded = [row for row in ranked if Decimal(row["weekly_remaining"]) >= floor]
236
+ if credit_mode and funded[0]["funding_tier"] == "credits":
237
+ # Subscription windows cannot rank credit-funded work. Credit capacity per
238
+ # identity holder decides, with the same deterministic LRU/spread for ties.
239
+ best_credit = min(credit_rank(row) for row in funded)
240
+ banded = [row for row in funded if credit_rank(row) == best_credit]
241
+ floor, healthy = None, []
229
242
  winner = min(banded, key=_band_key(request["reservation_key"]))
230
243
  rows.sort(key=_snapshot_key)
231
244
  snapshot = canonical_sha256(rows)
@@ -239,6 +252,8 @@ def select(request: object) -> dict:
239
252
  "headroom_band": band_text,
240
253
  "session_gate": gate_text,
241
254
  "candidate_snapshot_digest": snapshot, "selected_identity": chosen}
255
+ if credit_mode:
256
+ digest_input.update(funding_proof(request, winner))
242
257
  score_fields = ("quota_known", "weekly_pct", "session_pct", "weekly_remaining",
243
258
  "session_remaining", "effective_headroom", "last_selected_at")
244
259
  response = {"schema": SCHEMA, "selector_version": SELECTOR_VERSION, "ok": True, **chosen,
@@ -256,6 +271,9 @@ def select(request: object) -> dict:
256
271
  "session_ok_count": len(healthy),
257
272
  "candidate_snapshot_digest": snapshot,
258
273
  "selection_digest": canonical_sha256(digest_input)}
274
+ if credit_mode:
275
+ response.update(funding_proof(request, winner))
276
+ response["score_basis"].update(funding_score(winner))
259
277
  if request["policy"] == "reviewer" and not alternatives and _row_identity(winner) == producer:
260
278
  response["fallback_reason"] = "sole_eligible_account"
261
279
  return response
@@ -39,7 +39,8 @@ REQUEST_KEYS = {
39
39
  # headroom_band from its own setting and never sends session_gate, so the gate default
40
40
  # below is what every panel launch gets (operator's 2026-09-03 decision: session
41
41
  # headroom gates FIRST, then the weekly band picks among the survivors).
42
- OPTIONAL_REQUEST_KEYS = {"headroom_band", "session_gate"}
42
+ OPTIONAL_REQUEST_KEYS = {"headroom_band", "session_gate", "credit_fallback",
43
+ "required_funding_tier", "credit_max_age_seconds"}
43
44
  DEFAULT_HEADROOM_BAND = "30"
44
45
  DEFAULT_SESSION_GATE = "50"
45
46
  POLICIES = {
@@ -144,8 +145,12 @@ def _identity_error(value: object, field: str) -> str | None:
144
145
 
145
146
  def _candidate_error(value: object, index: int) -> str | None:
146
147
  field = f"candidates[{index}]"
147
- if not isinstance(value, dict) or set(value) != CANDIDATE_KEYS:
148
+ from selector_credits import OPTIONAL_CANDIDATE_FIELDS, credit_candidate_error
149
+ if not isinstance(value, dict) or not CANDIDATE_KEYS <= set(value) \
150
+ or set(value) - CANDIDATE_KEYS - OPTIONAL_CANDIDATE_FIELDS:
148
151
  return field
152
+ if error := credit_candidate_error(value, field):
153
+ return error
149
154
  for name in ("runner_id", "runner_generation"):
150
155
  item = value[name]
151
156
  if not isinstance(item, int) or isinstance(item, bool) or not 0 < item <= SAFE_INTEGER:
@@ -186,6 +191,16 @@ def request_error(request: object) -> tuple[str, dict] | None:
186
191
  keys = set(request)
187
192
  if not REQUEST_KEYS <= keys or keys - REQUEST_KEYS - OPTIONAL_REQUEST_KEYS:
188
193
  return "invalid_request", {"field": "$"}
194
+ if "credit_fallback" in request and not isinstance(request["credit_fallback"], bool):
195
+ return "invalid_request", {"field": "credit_fallback"}
196
+ if "required_funding_tier" in request and (not isinstance(request["required_funding_tier"], str)
197
+ or request["required_funding_tier"] not in {"included", "reserve", "credits"}):
198
+ return "invalid_request", {"field": "required_funding_tier"}
199
+ if "required_funding_tier" in request and request.get("credit_fallback") is not True:
200
+ return "invalid_request", {"field": "required_funding_tier"}
201
+ age = request.get("credit_max_age_seconds", 900)
202
+ if isinstance(age, bool) or not isinstance(age, int) or not 30 <= age <= 3600:
203
+ return "invalid_request", {"field": "credit_max_age_seconds"}
189
204
  for knob in ("headroom_band", "session_gate"):
190
205
  if knob not in request:
191
206
  continue
@@ -224,6 +239,9 @@ def request_error(request: object) -> tuple[str, dict] | None:
224
239
  if not isinstance(request["candidates"], list):
225
240
  return "invalid_request", {"field": "candidates"}
226
241
  for index, value in enumerate(request["candidates"]):
242
+ if isinstance(value, dict) and set(value) - CANDIDATE_KEYS \
243
+ and request.get("credit_fallback") is not True:
244
+ return "invalid_request", {"field": f"candidates[{index}]"}
227
245
  if error := _candidate_error(value, index):
228
246
  return "invalid_request", {"field": error}
229
247
  return None
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.50",
3
+ "version": "2.0.52",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,154 @@
1
+ """Pure contract checks for spendable credit telemetry, separate from earned resets."""
2
+
3
+ from pathlib import Path
4
+ import sys
5
+ import unittest
6
+
7
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib"))
8
+ from codex_credits import collect_credits, credit_view, failed_credits, snapshot_from_usage
9
+
10
+ NOW = 1791115200
11
+
12
+
13
+ def payload(**updates):
14
+ return {"account_id": "account-test", "credits": {
15
+ "has_credits": True, "unlimited": False, "balance": "1234.5000"},
16
+ "rate_limit": {"allowed": False}, "spend_control_reached": False, **updates}
17
+
18
+
19
+ class SpendableCreditsTest(unittest.TestCase):
20
+ def test_provider_balance_and_identity_are_preserved_without_float_rounding(self):
21
+ raw = payload()
22
+ raw["credits"]["balance"] = "12345678901234567890.123456789"
23
+ result = snapshot_from_usage(raw, "account-test", NOW)
24
+ self.assertEqual(result["balance"], "12345678901234567890.123456789")
25
+ self.assertEqual(result["account_id"], "account-test")
26
+ self.assertEqual(result["fetched_at"], "2026-10-04T12:00:00Z")
27
+ self.assertIs(result["ordinary_usage_allowed"], False)
28
+ self.assertIs(result["spend_control_reached"], False)
29
+
30
+ def test_measured_zero_unlimited_and_unknown_balance_remain_distinct(self):
31
+ for raw, expected in (({"has_credits": False, "unlimited": False, "balance": "0.00"}, "0"),
32
+ ({"has_credits": True, "unlimited": True, "balance": None}, None),
33
+ ({"has_credits": True, "unlimited": False}, None)):
34
+ with self.subTest(raw=raw):
35
+ result = snapshot_from_usage(payload(credits=raw), "account-test", NOW)
36
+ self.assertEqual(result["balance"], expected)
37
+ self.assertEqual(result["unlimited"], raw["unlimited"])
38
+
39
+ def test_earned_resets_never_become_a_spendable_balance(self):
40
+ self.assertIsNone(collect_credits({"rate_limit_reset_credits": {"available_count": 3}},
41
+ "account-test", NOW))
42
+ self.assertIsNone(collect_credits({"credits": None}, "account-test", NOW))
43
+
44
+ def test_malformed_balances_and_truthy_flags_do_not_claim_available_credit(self):
45
+ for invalid in (True, 12.34, -1, "-1", "NaN", "Infinity", "1e3", "1,000", "9" * 129):
46
+ with self.subTest(invalid=invalid):
47
+ raw = payload()
48
+ raw["credits"]["balance"] = invalid
49
+ self.assertIsNone(collect_credits(raw, "account-test", NOW))
50
+ raw = payload()
51
+ raw["credits"]["has_credits"] = "true"
52
+ self.assertIsNone(collect_credits(raw, "account-test", NOW))
53
+
54
+ def test_response_identity_must_agree_with_authenticated_account(self):
55
+ previous = snapshot_from_usage(payload(), "account-test", NOW)
56
+ bad = collect_credits(payload(account_id="other-account"), "account-test", NOW + 10, previous)
57
+ self.assertEqual(bad["account_id"], "account-test")
58
+ self.assertEqual(bad["last_error"], "account_mismatch")
59
+ self.assertEqual(bad["fetched_at"], previous["fetched_at"])
60
+ no_response_id = snapshot_from_usage(payload(account_id=None), "account-test", NOW)
61
+ self.assertEqual(no_response_id["account_id"], "account-test")
62
+
63
+ def test_failure_preserves_last_observation_then_success_clears_it(self):
64
+ previous = snapshot_from_usage(payload(), "account-test", NOW)
65
+ failed = failed_credits(previous, "http_429", NOW + 10)
66
+ self.assertEqual(failed["balance"], "1234.5")
67
+ self.assertEqual(failed["fetched_at"], previous["fetched_at"])
68
+ self.assertTrue(failed["stale"])
69
+ self.assertEqual(failed["last_error"], "http_429")
70
+ missing = collect_credits({}, "account-test", NOW + 20, previous)
71
+ self.assertEqual(missing["last_error"], "missing_credits")
72
+ recovered = collect_credits(payload(), "account-test", NOW + 30, failed)
73
+ self.assertNotIn("last_error", recovered)
74
+ self.assertFalse(credit_view(recovered, NOW + 30)["stale"])
75
+
76
+ def test_staleness_uses_credit_timestamp_and_rejects_future_or_invalid_time(self):
77
+ value = snapshot_from_usage(payload(), "account-test", NOW)
78
+ self.assertFalse(credit_view(value, NOW + 900)["stale"])
79
+ self.assertTrue(credit_view(value, NOW + 901)["stale"])
80
+ self.assertTrue(credit_view(value, NOW - 31)["stale"])
81
+ self.assertIsNone(credit_view({**value, "fetched_at": "2026-10-04T12:00:00"}, NOW))
82
+ self.assertIsNone(credit_view({**value, "fetched_at": {}}, NOW))
83
+
84
+ def test_soft_failure_never_erases_a_known_auth_or_identity_failure(self):
85
+ previous = snapshot_from_usage(payload(), "account-test", NOW)
86
+ for error in ("unauthenticated", "account_mismatch"):
87
+ failed = failed_credits(previous, error, NOW + 10)
88
+ retry = failed_credits(failed, "network_unreachable", NOW + 20)
89
+ self.assertEqual(retry["last_error"], error)
90
+ self.assertEqual(retry["last_error_at"], failed["last_error_at"])
91
+ self.assertNotIn("last_error", collect_credits(payload(), "account-test", NOW + 30, retry))
92
+
93
+ def test_transport_failure_diagnostics_preserve_paid_balance_and_its_timestamp(self):
94
+ previous = snapshot_from_usage(payload(), "account-test", NOW)
95
+ for error in ("network_unreachable", "dns_unavailable", "usage_timeout", "http_401", "http_403"):
96
+ failed = failed_credits(previous, error, NOW + 10)
97
+ self.assertEqual(failed["last_error"], error)
98
+ self.assertEqual(failed["balance"], previous["balance"])
99
+ self.assertEqual(failed["fetched_at"], previous["fetched_at"])
100
+
101
+ def test_missing_credit_schema_does_not_hide_identity_mismatch(self):
102
+ previous = snapshot_from_usage(payload(), "account-test", NOW)
103
+ result = collect_credits({"account_id": "other"}, "account-test", NOW + 10, previous)
104
+ self.assertEqual(result["last_error"], "account_mismatch")
105
+
106
+ def test_missing_balance_keeps_money_but_obeys_new_spend_control(self):
107
+ previous = snapshot_from_usage(payload(), "account-test", NOW)
108
+ for raw in (payload(credits={"has_credits": True, "unlimited": False, "balance": None}),
109
+ payload(credits=None)):
110
+ raw["spend_control_reached"] = True
111
+ result = collect_credits(raw, "account-test", NOW + 10, previous)
112
+ self.assertEqual(result["balance"], previous["balance"])
113
+ self.assertEqual(result["fetched_at"], previous["fetched_at"])
114
+ self.assertTrue(result["spend_control_reached"])
115
+ self.assertIn(result["last_error"], ("invalid_credits", "missing_credits"))
116
+
117
+ def test_partial_read_never_clears_previous_spending_restrictions(self):
118
+ previous = snapshot_from_usage(payload(spend_control_reached=True,
119
+ rate_limit_reached_type="workspace_member_usage_limit_reached"), "account-test", NOW)
120
+ raw = payload(credits=None, spend_control_reached=False, rate_limit_reached_type="rate_limit_reached")
121
+ result = collect_credits(raw, "account-test", NOW + 10, previous)
122
+ self.assertTrue(result["spend_control_reached"])
123
+ self.assertEqual(result["limit_reached_type"], "workspace_member_usage_limit_reached")
124
+ self.assertFalse(collect_credits(payload(), "account-test", NOW + 20, result)["spend_control_reached"])
125
+
126
+ def test_admission_facts_are_never_inferred_from_percentages(self):
127
+ raw = payload(rate_limit={"primary_window": {"used_percent": 0}})
128
+ snapshot = snapshot_from_usage(raw, "account-test", NOW)
129
+ self.assertIsNone(snapshot["ordinary_usage_allowed"])
130
+ raw["spend_control_reached"] = "false"
131
+ raw["rate_limit_reached_type"] = {"unexpected": "shape"}
132
+ snapshot = snapshot_from_usage(raw, "account-test", NOW)
133
+ self.assertIsNone(snapshot["spend_control_reached"])
134
+ self.assertEqual(snapshot["limit_reached_type"], "unknown")
135
+
136
+ def test_documented_raw_and_app_server_nested_admission_fields(self):
137
+ raw = payload(spend_control={"reached": True},
138
+ rate_limit_reached_type={"type": "workspace_member_usage_limit_reached"})
139
+ raw.pop("spend_control_reached")
140
+ snapshot = snapshot_from_usage(raw, "account-test", NOW)
141
+ self.assertTrue(snapshot["spend_control_reached"])
142
+ self.assertEqual(snapshot["limit_reached_type"], "workspace_member_usage_limit_reached")
143
+ app_server = {"accountId": "account-test", "ordinaryUsageAllowed": False, "rateLimits": {
144
+ "credits": {"hasCredits": True, "unlimited": False, "balance": "12.34"},
145
+ "rateLimitReachedType": "rate_limit_reached", "spendControlReached": False}}
146
+ snapshot = snapshot_from_usage(app_server, "account-test", NOW)
147
+ self.assertEqual(snapshot["limit_reached_type"], "rate_limit_reached")
148
+ self.assertEqual(snapshot["balance"], "12.34")
149
+ self.assertIs(snapshot["ordinary_usage_allowed"], False)
150
+ self.assertIs(snapshot["spend_control_reached"], False)
151
+
152
+
153
+ if __name__ == "__main__":
154
+ unittest.main()