claude-multiacc 2.0.51 → 2.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/codex +1 -1
- package/bin/codex-accounts +28 -41
- package/docs/CODEX.md +43 -15
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/codex_credit_controls.py +27 -0
- package/lib/codex_credit_selection.py +10 -4
- package/lib/codex_credits.py +28 -32
- package/lib/codex_limits_lock.py +56 -0
- package/lib/codex_usage_fetch.py +85 -0
- package/lib/selector_credit_scope.py +33 -3
- package/lib/selector_credits.py +10 -4
- package/lib/selector_funding.py +16 -14
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/test_codex_reset_credits.py +42 -0
- package/tests/test_codex_reset_credits_polling.py +58 -1
- package/tests/test_codex_reset_fetch.py +123 -0
- package/tests/test_selector_cached_credits.py +135 -0
- package/tests/test_selector_credit_scope.py +5 -5
- package/tests/test_selector_credit_shim.py +3 -3
- package/tests/test_selector_credits.py +3 -2
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from decimal import Decimal
|
|
6
6
|
|
|
7
7
|
from selector_primitives import timestamp
|
|
8
|
+
from selector_credits import HARD_CREDIT_ERRORS, normalize_credits
|
|
8
9
|
|
|
9
10
|
FINANCIAL_FIELDS = ("has_credits", "unlimited", "balance", "account_id")
|
|
10
11
|
|
|
@@ -17,7 +18,8 @@ def actor_email(raw: dict) -> str | None:
|
|
|
17
18
|
def wallet_available(credits: dict) -> bool:
|
|
18
19
|
balance = credits.get("balance")
|
|
19
20
|
funds = credits["unlimited"] or (credits["has_credits"] and balance is not None and Decimal(balance) > 0)
|
|
20
|
-
return bool(credits["
|
|
21
|
+
return bool(credits["account_id"] and credits["fetched_at"]
|
|
22
|
+
and credits["last_error"] not in HARD_CREDIT_ERRORS and funds)
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
def merge_wallet(local: dict, wallet: dict | None) -> dict:
|
|
@@ -25,7 +27,7 @@ def merge_wallet(local: dict, wallet: dict | None) -> dict:
|
|
|
25
27
|
return local
|
|
26
28
|
merged = dict(local)
|
|
27
29
|
merged.update({key: wallet[key] for key in FINANCIAL_FIELDS})
|
|
28
|
-
merged["available"] = bool(local["
|
|
30
|
+
merged["available"] = bool(local["fetched_at"] and local["last_error"] not in HARD_CREDIT_ERRORS
|
|
29
31
|
and not local["spend_control_reached"] and wallet["available"])
|
|
30
32
|
return merged
|
|
31
33
|
|
|
@@ -41,7 +43,7 @@ def actor_key(raw: dict) -> tuple:
|
|
|
41
43
|
def actor_refusals(candidates: list[dict]) -> dict:
|
|
42
44
|
refusals = {}
|
|
43
45
|
for raw in candidates:
|
|
44
|
-
if not raw.get("credit_blocked_at") or (raw.get("credits") or {}).get("last_error"):
|
|
46
|
+
if not raw.get("credit_blocked_at") or (raw.get("credits") or {}).get("last_error") == "account_mismatch":
|
|
45
47
|
continue
|
|
46
48
|
try:
|
|
47
49
|
_, blocked = timestamp(raw["credit_blocked_at"])
|
|
@@ -53,3 +55,31 @@ def actor_refusals(candidates: list[dict]) -> dict:
|
|
|
53
55
|
refusals[key] = {"credit_blocked_at": blocked,
|
|
54
56
|
"credit_blocked_balance": raw.get("credit_blocked_balance")}
|
|
55
57
|
return refusals
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def actor_observations(candidates: list[dict], now, max_age: int) -> dict:
|
|
61
|
+
observations = {}
|
|
62
|
+
for raw in candidates:
|
|
63
|
+
credits = normalize_credits(raw.get("credits"), now, max_age)
|
|
64
|
+
if not credits["fetched_at"] or credits["last_error"] == "account_mismatch":
|
|
65
|
+
continue
|
|
66
|
+
key = actor_key(raw)
|
|
67
|
+
sticky = bool(credits["last_error"] and credits["spend_control_reached"])
|
|
68
|
+
rank = (sticky, credits["fetched_at"], credits["spend_control_reached"],
|
|
69
|
+
credits["ordinary_usage_allowed"] is False)
|
|
70
|
+
previous = observations.get(key)
|
|
71
|
+
if previous is None or rank > previous[0]:
|
|
72
|
+
observations[key] = rank, credits
|
|
73
|
+
return {key: value[1] for key, value in observations.items()}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def merge_actor(local: dict, observation: dict | None) -> dict:
|
|
77
|
+
# A partial response can confirm a new restriction while retaining old money's
|
|
78
|
+
# timestamp. A peer's newer money is not proof that this local restriction lifted.
|
|
79
|
+
if observation is None or (local["last_error"] and local["spend_control_reached"]):
|
|
80
|
+
return local
|
|
81
|
+
merged = dict(local)
|
|
82
|
+
for key in ("ordinary_usage_allowed", "spend_control_reached", "limit_reached_type"):
|
|
83
|
+
merged[key] = observation[key]
|
|
84
|
+
merged["available"] = wallet_available(local) and not merged["spend_control_reached"]
|
|
85
|
+
return merged
|
package/lib/selector_credits.py
CHANGED
|
@@ -8,6 +8,7 @@ from decimal import Decimal, InvalidOperation
|
|
|
8
8
|
from selector_primitives import DECIMAL_RE, timestamp
|
|
9
9
|
|
|
10
10
|
CAPABILITY = "codex_credits_v1"
|
|
11
|
+
HARD_CREDIT_ERRORS = {"account_mismatch", "unauthenticated", "refusal_read_failed"}
|
|
11
12
|
CREDIT_FIELDS = {
|
|
12
13
|
"has_credits", "unlimited", "balance", "fetched_at", "account_id", "ordinary_usage_allowed",
|
|
13
14
|
"spend_control_reached", "limit_reached_type", "last_error", "last_error_at", "stale", "age_seconds"}
|
|
@@ -63,7 +64,10 @@ def normalize_credits(raw: object, now: datetime, max_age: int = 900) -> dict:
|
|
|
63
64
|
account = account.strip() if isinstance(account, str) and len(account) <= 256 else None
|
|
64
65
|
try:
|
|
65
66
|
observed, fetched_at = timestamp(raw.get("fetched_at"))
|
|
66
|
-
|
|
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
|
|
67
71
|
except (ValueError, TypeError):
|
|
68
72
|
fetched_at, fresh = None, False
|
|
69
73
|
reached = raw.get("limit_reached_type")
|
|
@@ -78,8 +82,9 @@ def normalize_credits(raw: object, now: datetime, max_age: int = 900) -> dict:
|
|
|
78
82
|
"ordinary_usage_allowed": raw.get("ordinary_usage_allowed")
|
|
79
83
|
if isinstance(raw.get("ordinary_usage_allowed"), bool) else None,
|
|
80
84
|
"spend_control_reached": restricted, "limit_reached_type": reached,
|
|
81
|
-
"last_error":
|
|
82
|
-
"available": bool(
|
|
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)}
|
|
83
88
|
|
|
84
89
|
|
|
85
90
|
def refusal_recovered(raw: dict, credits: dict, now: datetime) -> bool:
|
|
@@ -96,4 +101,5 @@ def refusal_recovered(raw: dict, credits: dict, now: datetime) -> bool:
|
|
|
96
101
|
current = credits.get("balance")
|
|
97
102
|
increased = old is not None and current is not None and Decimal(current) > Decimal(old)
|
|
98
103
|
cooled = (now - blocked_time).total_seconds() >= 600
|
|
99
|
-
return bool(fetched > blocked_time and credits["available"] and
|
|
104
|
+
return bool(fetched > blocked_time and credits["available"] and credits["fresh"]
|
|
105
|
+
and not credits["last_error"] and (increased or cooled))
|
package/lib/selector_funding.py
CHANGED
|
@@ -5,8 +5,9 @@ from __future__ import annotations
|
|
|
5
5
|
from decimal import Decimal, localcontext
|
|
6
6
|
|
|
7
7
|
from selector_credits import CAPABILITY, normalize_credits, refusal_recovered
|
|
8
|
-
from selector_credit_scope import
|
|
9
|
-
|
|
8
|
+
from selector_credit_scope import (
|
|
9
|
+
actor_email, actor_key, actor_observations, actor_refusals, merge_actor, merge_wallet, wallet_available,
|
|
10
|
+
)
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def wallet_snapshots(candidates: list[dict], now, max_age: int) -> dict:
|
|
@@ -14,21 +15,20 @@ def wallet_snapshots(candidates: list[dict], now, max_age: int) -> dict:
|
|
|
14
15
|
for raw in candidates:
|
|
15
16
|
credits = normalize_credits(raw.get("credits"), now, max_age)
|
|
16
17
|
key = credits["account_id"]
|
|
17
|
-
|
|
18
|
-
if not key or error in {"account_mismatch", "unauthenticated", "http_401", "http_403"}:
|
|
18
|
+
if not key:
|
|
19
19
|
continue
|
|
20
|
-
|
|
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))
|
|
21
23
|
observed = credits["fetched_at"] or ""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
except ValueError:
|
|
27
|
-
pass
|
|
28
|
-
credits["wallet_observed_at"] = observed
|
|
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
|
|
29
28
|
previous = wallets.get(key)
|
|
30
|
-
rank = (observed, not credits["available"])
|
|
31
|
-
if previous is None or rank > (previous["
|
|
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
32
|
wallets[key] = credits
|
|
33
33
|
return wallets
|
|
34
34
|
|
|
@@ -37,12 +37,14 @@ def apply_funding(rows: list[dict], candidates: list[dict], now, request: dict)
|
|
|
37
37
|
max_age = request.get("credit_max_age_seconds", 900)
|
|
38
38
|
wallets = wallet_snapshots(candidates, now, max_age)
|
|
39
39
|
refusals = actor_refusals(candidates)
|
|
40
|
+
observations = actor_observations(candidates, now, max_age)
|
|
40
41
|
holders = {}
|
|
41
42
|
for raw in candidates:
|
|
42
43
|
account = (raw.get("credits") or {}).get("account_id")
|
|
43
44
|
holders[account] = max(holders.get(account, 0), raw.get("credit_active_holders", 0))
|
|
44
45
|
for row, raw in zip(rows, candidates):
|
|
45
46
|
credits = normalize_credits(raw.get("credits"), now, max_age)
|
|
47
|
+
credits = merge_actor(credits, observations.get(actor_key(raw)))
|
|
46
48
|
credits = merge_wallet(credits, wallets.get(credits["account_id"]))
|
|
47
49
|
receipt = dict(raw, **refusals.get(actor_key(raw), {}))
|
|
48
50
|
known = row["quota_known"]
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -81,6 +81,48 @@ class SpendableCreditsTest(unittest.TestCase):
|
|
|
81
81
|
self.assertIsNone(credit_view({**value, "fetched_at": "2026-10-04T12:00:00"}, NOW))
|
|
82
82
|
self.assertIsNone(credit_view({**value, "fetched_at": {}}, NOW))
|
|
83
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
|
+
|
|
84
126
|
def test_admission_facts_are_never_inferred_from_percentages(self):
|
|
85
127
|
raw = payload(rate_limit={"primary_window": {"used_percent": 0}})
|
|
86
128
|
snapshot = snapshot_from_usage(raw, "account-test", NOW)
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"""Existing Codex usage polling carries paid balances without extra provider requests."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
from datetime import datetime
|
|
4
5
|
import os
|
|
5
6
|
import subprocess
|
|
6
7
|
import time
|
|
7
8
|
import unittest
|
|
9
|
+
from unittest.mock import patch
|
|
8
10
|
|
|
9
11
|
import test_codex_reset as fixtures
|
|
10
12
|
|
|
@@ -50,7 +52,7 @@ class CreditPollingTest(fixtures.CodexPoolSandbox, unittest.TestCase):
|
|
|
50
52
|
self.assertEqual(listed["accounts"][0]["usage"]["credits"]["balance"], "61812.452075")
|
|
51
53
|
self.assertEqual(self.server.state["usage_gets"], 1)
|
|
52
54
|
|
|
53
|
-
def
|
|
55
|
+
def test_missing_credit_read_preserves_previous_amount_and_reports_staleness(self):
|
|
54
56
|
self.command("limits", "--force", "--quiet")
|
|
55
57
|
path = self.pool / "acct-01/limits.json"
|
|
56
58
|
before = json.loads(path.read_text())["credits"]
|
|
@@ -80,6 +82,61 @@ class CreditPollingTest(fixtures.CodexPoolSandbox, unittest.TestCase):
|
|
|
80
82
|
self.assertEqual(stored["credits"]["balance"], "61812.452075")
|
|
81
83
|
self.assertIsNone(stored["credits"]["ordinary_usage_allowed"])
|
|
82
84
|
|
|
85
|
+
def test_success_timestamp_is_after_response_instead_of_pass_start(self):
|
|
86
|
+
original = fixtures.ResetHandler.do_GET
|
|
87
|
+
observed = []
|
|
88
|
+
|
|
89
|
+
def delayed(handler):
|
|
90
|
+
if handler.path == "/wham/usage":
|
|
91
|
+
time.sleep(0.05)
|
|
92
|
+
observed.append(time.time())
|
|
93
|
+
original(handler)
|
|
94
|
+
|
|
95
|
+
with patch.object(fixtures.ResetHandler, "do_GET", delayed):
|
|
96
|
+
self.command("limits", "--force", "--quiet")
|
|
97
|
+
stored = json.loads((self.pool / "acct-01/limits.json").read_text())
|
|
98
|
+
fetched = datetime.fromisoformat(stored["credits"]["fetched_at"].replace("Z", "+00:00")).timestamp()
|
|
99
|
+
self.assertGreaterEqual(fetched, observed[0])
|
|
100
|
+
self.assertEqual(stored["fetched_at"], int(fetched))
|
|
101
|
+
|
|
102
|
+
def test_failed_http_read_keeps_balance_and_next_pass_honors_backoff(self):
|
|
103
|
+
self.command("limits", "--force", "--quiet")
|
|
104
|
+
path = self.pool / "acct-01/limits.json"
|
|
105
|
+
previous = json.loads(path.read_text())
|
|
106
|
+
original = fixtures.ResetHandler.do_GET
|
|
107
|
+
calls = []
|
|
108
|
+
|
|
109
|
+
def unavailable(handler):
|
|
110
|
+
if handler.path == "/wham/usage":
|
|
111
|
+
calls.append(time.time())
|
|
112
|
+
handler.send_error(503)
|
|
113
|
+
else:
|
|
114
|
+
original(handler)
|
|
115
|
+
|
|
116
|
+
with patch.object(fixtures.ResetHandler, "do_GET", unavailable):
|
|
117
|
+
self.command("limits", "--force", "--quiet")
|
|
118
|
+
stored = json.loads(path.read_text())
|
|
119
|
+
self.assertEqual(stored["credits"]["balance"], previous["credits"]["balance"])
|
|
120
|
+
self.assertEqual(stored["credits"]["fetched_at"], previous["credits"]["fetched_at"])
|
|
121
|
+
self.assertEqual(stored["credits"]["last_error"], "http_error")
|
|
122
|
+
self.assertGreaterEqual(stored["last_error_at"], int(calls[-1]))
|
|
123
|
+
stored["fetched_at"] = 0
|
|
124
|
+
path.write_text(json.dumps(stored))
|
|
125
|
+
self.command("limits", "--quiet")
|
|
126
|
+
self.assertEqual(len(calls), 2)
|
|
127
|
+
|
|
128
|
+
def test_legacy_429_backoff_keeps_its_existing_diagnostic(self):
|
|
129
|
+
path = self.pool / "acct-01/limits.json"
|
|
130
|
+
for error in (None, "http_429", "network_unreachable"):
|
|
131
|
+
previous = {"fetched_at": 0, "retry_after": int(time.time()) + 300}
|
|
132
|
+
if error is not None:
|
|
133
|
+
previous["last_error"] = error
|
|
134
|
+
path.write_text(json.dumps(previous))
|
|
135
|
+
result = self.command("limits")
|
|
136
|
+
reason = "usage read failure" if error == "network_unreachable" else "429"
|
|
137
|
+
self.assertIn(f"acct-01: backing off after {reason}", result.stdout)
|
|
138
|
+
self.assertEqual(self.server.state["usage_gets"], 0)
|
|
139
|
+
|
|
83
140
|
def test_earned_reset_requires_new_usage_before_authorizing_paid_fallback(self):
|
|
84
141
|
self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 1})]
|
|
85
142
|
self.command("limits", "--force", "--quiet", auto_reset="1")
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Network failures retain usage evidence, retry safely, and never steal a live poller's lock."""
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from email.message import Message
|
|
5
|
+
import errno
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
import unittest
|
|
15
|
+
from unittest.mock import patch
|
|
16
|
+
import urllib.error
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib"))
|
|
19
|
+
from codex_limits_lock import change_lock
|
|
20
|
+
from codex_usage_fetch import failure_kind, fetch_usage, retry_delay
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def http_error(code, **headers):
|
|
24
|
+
message = Message()
|
|
25
|
+
for key, value in headers.items():
|
|
26
|
+
message[key] = value
|
|
27
|
+
result = urllib.error.HTTPError("https://example.invalid/usage", code, "failure", message, io.BytesIO())
|
|
28
|
+
result.close()
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def response(value):
|
|
34
|
+
yield io.BytesIO(json.dumps(value).encode())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UsageFetchTest(unittest.TestCase):
|
|
38
|
+
def test_transient_route_failure_retries_once_and_returns_response(self):
|
|
39
|
+
failed = urllib.error.URLError(OSError(errno.EHOSTUNREACH, "No route to host"))
|
|
40
|
+
with patch("codex_usage_fetch.urllib.request.urlopen", side_effect=[failed, response({"credits": {}})]) as read:
|
|
41
|
+
self.assertEqual(fetch_usage("request"), {"credits": {}})
|
|
42
|
+
self.assertEqual(read.call_count, 2)
|
|
43
|
+
|
|
44
|
+
def test_dns_failure_is_bounded_to_two_requests(self):
|
|
45
|
+
failed = urllib.error.URLError(socket.gaierror(socket.EAI_AGAIN, "unavailable"))
|
|
46
|
+
with patch("codex_usage_fetch.urllib.request.urlopen", side_effect=failed) as read:
|
|
47
|
+
with self.assertRaises(urllib.error.URLError):
|
|
48
|
+
fetch_usage("request")
|
|
49
|
+
self.assertEqual(read.call_count, 2)
|
|
50
|
+
self.assertEqual(failure_kind(failed), "dns_unavailable")
|
|
51
|
+
|
|
52
|
+
def test_elapsed_budget_prevents_retry_after_slow_failure(self):
|
|
53
|
+
with patch("codex_usage_fetch.time.monotonic", side_effect=[0, 0, 16]), \
|
|
54
|
+
patch("codex_usage_fetch.urllib.request.urlopen", side_effect=TimeoutError()) as read:
|
|
55
|
+
with self.assertRaises(TimeoutError):
|
|
56
|
+
fetch_usage("request")
|
|
57
|
+
self.assertEqual(read.call_count, 1)
|
|
58
|
+
self.assertEqual(read.call_args.kwargs["timeout"], 10)
|
|
59
|
+
|
|
60
|
+
def test_http_auth_and_rate_limits_never_retry_immediately(self):
|
|
61
|
+
for code in (401, 403, 429):
|
|
62
|
+
with self.subTest(code=code), \
|
|
63
|
+
patch("codex_usage_fetch.urllib.request.urlopen", side_effect=http_error(code)) as read:
|
|
64
|
+
with self.assertRaises(urllib.error.HTTPError):
|
|
65
|
+
fetch_usage("request")
|
|
66
|
+
self.assertEqual(read.call_count, 1)
|
|
67
|
+
|
|
68
|
+
def test_transient_provider_error_retries_unless_retry_after_present(self):
|
|
69
|
+
for error, count in ((http_error(503), 2), (http_error(503, **{"Retry-After": "60"}), 1)):
|
|
70
|
+
with patch("codex_usage_fetch.urllib.request.urlopen", side_effect=error) as read:
|
|
71
|
+
with self.assertRaises(urllib.error.HTTPError):
|
|
72
|
+
fetch_usage("request")
|
|
73
|
+
self.assertEqual(read.call_count, count)
|
|
74
|
+
|
|
75
|
+
def test_retry_after_seconds_and_date_are_honored(self):
|
|
76
|
+
self.assertEqual(retry_delay(http_error(429, **{"Retry-After": "2100"}), {}, 0), 2100)
|
|
77
|
+
self.assertEqual(retry_delay(http_error(429, **{"Retry-After": "Thu, 01 Jan 1970 00:02:00 GMT"}), {}, 10), 110)
|
|
78
|
+
self.assertEqual(retry_delay(TimeoutError(), {"backoff": 600}, 0), 900)
|
|
79
|
+
self.assertEqual(retry_delay(http_error(429), {"backoff": 1200}, 0), 1800)
|
|
80
|
+
|
|
81
|
+
def test_precise_safe_failures_and_edge_challenge(self):
|
|
82
|
+
self.assertEqual(failure_kind(urllib.error.URLError(OSError(errno.EHOSTUNREACH, "secret"))),
|
|
83
|
+
"network_unreachable")
|
|
84
|
+
self.assertEqual(failure_kind(TimeoutError("secret")), "usage_timeout")
|
|
85
|
+
self.assertEqual(failure_kind(http_error(403, **{"cf-mitigated": "challenge"})), "edge_challenge")
|
|
86
|
+
with patch("codex_usage_fetch.urllib.request.urlopen", return_value=response([])) as read:
|
|
87
|
+
with self.assertRaises(ValueError) as result:
|
|
88
|
+
fetch_usage("request")
|
|
89
|
+
self.assertEqual(failure_kind(result.exception), "invalid_usage")
|
|
90
|
+
self.assertEqual(read.call_count, 1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class UsageLockTest(unittest.TestCase):
|
|
94
|
+
def test_old_live_collector_is_not_replaced_and_wrong_owner_cannot_release(self):
|
|
95
|
+
with tempfile.TemporaryDirectory(prefix="codex-lock-test-") as directory:
|
|
96
|
+
path = Path(directory) / "limits"
|
|
97
|
+
with patch("codex_limits_lock.process_birth", side_effect=lambda pid: str(pid)):
|
|
98
|
+
self.assertTrue(change_lock(path, 11))
|
|
99
|
+
os.utime(path, (time.time() - 600, time.time() - 600))
|
|
100
|
+
self.assertFalse(change_lock(path, 12))
|
|
101
|
+
self.assertFalse(change_lock(path, 12, release=True))
|
|
102
|
+
self.assertTrue(change_lock(path, 11, release=True))
|
|
103
|
+
|
|
104
|
+
def test_dead_owner_is_reclaimed_without_waiting_for_age(self):
|
|
105
|
+
with tempfile.TemporaryDirectory(prefix="codex-lock-test-") as directory:
|
|
106
|
+
path = Path(directory) / "limits"
|
|
107
|
+
path.mkdir()
|
|
108
|
+
(path / "owner.json").write_text(json.dumps({"pid": 11, "birth": "old"}))
|
|
109
|
+
with patch("codex_limits_lock.process_birth", side_effect=lambda pid: "new" if pid == 12 else ""):
|
|
110
|
+
self.assertTrue(change_lock(path, 12))
|
|
111
|
+
self.assertTrue(change_lock(path, 12, release=True))
|
|
112
|
+
|
|
113
|
+
def test_reused_pid_cannot_keep_abandoned_lock(self):
|
|
114
|
+
with tempfile.TemporaryDirectory(prefix="codex-lock-test-") as directory:
|
|
115
|
+
path = Path(directory) / "limits"
|
|
116
|
+
path.mkdir()
|
|
117
|
+
(path / "owner.json").write_text(json.dumps({"pid": 11, "birth": "old"}))
|
|
118
|
+
with patch("codex_limits_lock.process_birth", return_value="new"):
|
|
119
|
+
self.assertTrue(change_lock(path, 12))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
unittest.main()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Cached confirmed funds remain usable through telemetry outages, without an age cap."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from test_selector import NOW, _request
|
|
7
|
+
from test_selector_credits import _paid
|
|
8
|
+
import test_selector_credit_shim as shim_fixtures
|
|
9
|
+
from selector_policy import select
|
|
10
|
+
|
|
11
|
+
SOFT_ERRORS = (None, "usage_read_failed", "http_401", "http_403", "http_429", "invalid_usage",
|
|
12
|
+
"network_unreachable", "dns_unavailable", "usage_timeout", "tls_error", "connection_reset")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CachedCreditTests(unittest.TestCase):
|
|
16
|
+
def test_cached_positive_survives_every_soft_error_without_fallback_age_limit(self):
|
|
17
|
+
for error in SOFT_ERRORS:
|
|
18
|
+
with self.subTest(error=error):
|
|
19
|
+
row = _paid()
|
|
20
|
+
row["credits"].update(fetched_at="2020-01-01T00:00:00Z", stale=True, last_error=error,
|
|
21
|
+
last_error_at=NOW)
|
|
22
|
+
response = select(_request([row], credit_fallback=True, credit_max_age_seconds=30))
|
|
23
|
+
self.assertTrue(response["ok"], response)
|
|
24
|
+
self.assertEqual(response["funding_tier"], "credits")
|
|
25
|
+
self.assertEqual(response["score_basis"]["credit_balance"], "61812.452075")
|
|
26
|
+
|
|
27
|
+
def test_cached_unlimited_remains_distinct_from_finite_zero(self):
|
|
28
|
+
row = _paid()
|
|
29
|
+
row["credits"].update(fetched_at="2020-01-01T00:00:00Z", stale=True,
|
|
30
|
+
last_error="http_429", unlimited=True, balance="0", has_credits=False)
|
|
31
|
+
self.assertTrue(select(_request([row], credit_fallback=True))["ok"])
|
|
32
|
+
row["credits"]["unlimited"] = False
|
|
33
|
+
self.assertFalse(select(_request([row], credit_fallback=True))["ok"])
|
|
34
|
+
|
|
35
|
+
def test_newer_confirmed_zero_wins_over_old_positive_with_later_refresh_failure(self):
|
|
36
|
+
old, zero = _paid(account_id="old"), _paid(account_id="zero")
|
|
37
|
+
old["credits"].update(fetched_at="2020-01-01T00:00:00Z", last_error="network_unreachable",
|
|
38
|
+
last_error_at="2026-08-25T09:00:00Z", stale=True)
|
|
39
|
+
zero["credits"].update(balance="0", fetched_at="2026-08-25T08:00:00Z", stale=True)
|
|
40
|
+
self.assertFalse(select(_request([old, zero], credit_fallback=True))["ok"])
|
|
41
|
+
for error in ("unauthenticated", "account_mismatch", "refusal_read_failed"):
|
|
42
|
+
zero["credits"]["last_error"] = error
|
|
43
|
+
self.assertFalse(select(_request([old, zero], credit_fallback=True))["ok"])
|
|
44
|
+
zero["credits"].pop("last_error")
|
|
45
|
+
zero["credits"].update(balance="80", has_credits=False)
|
|
46
|
+
self.assertFalse(select(_request([old, zero], credit_fallback=True))["ok"])
|
|
47
|
+
zero["credits"]["has_credits"] = True
|
|
48
|
+
result = select(_request([old, zero], credit_fallback=True))
|
|
49
|
+
self.assertTrue(result["ok"])
|
|
50
|
+
self.assertEqual(result["score_basis"]["credit_balance"], "80")
|
|
51
|
+
|
|
52
|
+
def test_new_unknown_read_does_not_replace_confirmed_wallet_money(self):
|
|
53
|
+
old, unknown = _paid(account_id="old"), _paid(account_id="unknown")
|
|
54
|
+
old["credits"].update(fetched_at="2020-01-01T00:00:00Z", stale=True,
|
|
55
|
+
last_error="network_unreachable")
|
|
56
|
+
unknown["credits"].update(fetched_at=NOW, balance=None)
|
|
57
|
+
response = select(_request([old, unknown], credit_fallback=True))
|
|
58
|
+
self.assertTrue(response["ok"])
|
|
59
|
+
self.assertEqual(response["score_basis"]["credit_balance"], "61812.452075")
|
|
60
|
+
unknown["credits"]["has_credits"] = False
|
|
61
|
+
self.assertFalse(select(_request([old, unknown], credit_fallback=True))["ok"])
|
|
62
|
+
unknown["credits"].update(has_credits=True, balance="0")
|
|
63
|
+
self.assertFalse(select(_request([old, unknown], credit_fallback=True))["ok"])
|
|
64
|
+
unknown["credits"]["balance"] = None
|
|
65
|
+
old["credits"]["balance"] = None
|
|
66
|
+
self.assertFalse(select(_request([old, unknown], credit_fallback=True))["ok"])
|
|
67
|
+
|
|
68
|
+
def test_cached_read_cannot_erase_actual_paid_refusal_even_after_cooldown(self):
|
|
69
|
+
row = _paid()
|
|
70
|
+
row.update(limited_reason="credits", credit_blocked_at="2026-08-25T08:00:00Z",
|
|
71
|
+
credit_blocked_balance="1")
|
|
72
|
+
for patch in ({"fetched_at": "2026-08-25T08:01:00Z"},
|
|
73
|
+
{"fetched_at": NOW, "last_error": "http_429"},
|
|
74
|
+
{"fetched_at": NOW, "stale": True}):
|
|
75
|
+
with self.subTest(patch=patch):
|
|
76
|
+
candidate = dict(row, credits=dict(row["credits"], **patch))
|
|
77
|
+
self.assertFalse(select(_request([candidate], credit_fallback=True))["ok"])
|
|
78
|
+
self.assertTrue(select(_request([row], credit_fallback=True))["ok"])
|
|
79
|
+
|
|
80
|
+
def test_newer_actor_restriction_wins_over_later_failed_refresh_of_old_allowance(self):
|
|
81
|
+
old, denied = _paid(account_id="old"), _paid(account_id="denied")
|
|
82
|
+
for row in (old, denied):
|
|
83
|
+
row["credit_actor_email"] = "same@example.com"
|
|
84
|
+
old["credits"].update(fetched_at="2020-01-01T00:00:00Z", last_error="http_429", last_error_at=NOW)
|
|
85
|
+
denied["credits"].update(fetched_at="2026-08-25T08:00:00Z", spend_control_reached=True)
|
|
86
|
+
self.assertFalse(select(_request([old, denied], credit_fallback=True))["ok"])
|
|
87
|
+
old["credits"]["fetched_at"] = denied["credits"]["fetched_at"]
|
|
88
|
+
self.assertFalse(select(_request([old, denied], credit_fallback=True))["ok"])
|
|
89
|
+
denied["credit_actor_email"] = "different@example.com"
|
|
90
|
+
self.assertEqual(select(_request([old, denied], credit_fallback=True))["account_id"], "old")
|
|
91
|
+
|
|
92
|
+
def test_failed_partial_read_restriction_is_not_erased_by_peer_monetary_timestamp(self):
|
|
93
|
+
blocked, peer = _paid(account_id="blocked"), _paid(account_id="peer")
|
|
94
|
+
for row in (blocked, peer):
|
|
95
|
+
row["credit_actor_email"] = "same@example.com"
|
|
96
|
+
blocked["credits"].update(fetched_at="2020-01-01T00:00:00Z", spend_control_reached=True,
|
|
97
|
+
last_error="invalid_credits", last_error_at=NOW)
|
|
98
|
+
self.assertFalse(select(_request([blocked, peer], credit_fallback=True))["ok"])
|
|
99
|
+
blocked["credits"].update(spend_control_reached=False, limit_reached_type="unknown")
|
|
100
|
+
self.assertFalse(select(_request([blocked, peer], credit_fallback=True))["ok"])
|
|
101
|
+
peer["credit_actor_email"] = "other@example.com"
|
|
102
|
+
self.assertEqual(select(_request([blocked, peer], credit_fallback=True))["account_id"], "peer")
|
|
103
|
+
peer["credit_actor_email"] = "same@example.com"
|
|
104
|
+
blocked["credits"].update(last_error=None, fetched_at=NOW, limit_reached_type="rate_limit_reached")
|
|
105
|
+
self.assertTrue(select(_request([blocked, peer], credit_fallback=True))["ok"])
|
|
106
|
+
|
|
107
|
+
def test_refusal_still_propagates_across_same_actor_copies_when_refresh_failed(self):
|
|
108
|
+
blocked, peer = _paid(account_id="blocked"), _paid(account_id="peer")
|
|
109
|
+
for row in (blocked, peer):
|
|
110
|
+
row["credit_actor_email"] = "same@example.com"
|
|
111
|
+
blocked.update(limited_reason="credits", credit_blocked_at="2026-08-25T08:59:59Z",
|
|
112
|
+
credit_blocked_balance="61812.452075")
|
|
113
|
+
blocked["credits"].update(last_error="network_unreachable", stale=True)
|
|
114
|
+
self.assertFalse(select(_request([blocked, peer], credit_fallback=True))["ok"])
|
|
115
|
+
|
|
116
|
+
def test_standalone_shim_accepts_cached_funds_and_keeps_durable_refusal(self):
|
|
117
|
+
case = shim_fixtures.CreditShimTests()
|
|
118
|
+
case.setUp()
|
|
119
|
+
self.addCleanup(case.doCleanups)
|
|
120
|
+
directory = case.account("acct-01", fetched_at="2020-01-01T00:00:00Z", stale=True,
|
|
121
|
+
last_error="network_unreachable")
|
|
122
|
+
self.assertIn("tier=credits", case.run_shim().stdout)
|
|
123
|
+
result = case.run_shim(False, FAKE_ERROR="You've hit your usage limit")
|
|
124
|
+
self.assertEqual(result.returncode, 1)
|
|
125
|
+
self.assertIn("tier=hard", case.run_shim().stdout)
|
|
126
|
+
receipt = json.loads((directory / ".credit-refused.json").read_text())
|
|
127
|
+
receipt["credit_blocked_at"] = "2019-01-01T00:00:00Z"
|
|
128
|
+
(directory / ".credit-refused.json").write_text(json.dumps(receipt))
|
|
129
|
+
self.assertIn("tier=hard", case.run_shim().stdout)
|
|
130
|
+
case.account("acct-01")
|
|
131
|
+
self.assertIn("tier=credits", case.run_shim().stdout)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
unittest.main()
|