claude-multiacc 2.0.51 → 2.0.53
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/README.md +10 -0
- 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/claude_pool_prepare.py +204 -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/common.sh +8 -2
- 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 +2 -2
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_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_account_seeding.py +13 -0
- package/tests/test_claude_pool_prepare.py +107 -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
package/lib/codex_credits.py
CHANGED
|
@@ -9,17 +9,16 @@ import os
|
|
|
9
9
|
import re
|
|
10
10
|
import time
|
|
11
11
|
|
|
12
|
+
from codex_usage_fetch import TRANSPORT_ERRORS
|
|
13
|
+
from codex_credit_controls import limit_type as _limit_type, usage_controls as _controls
|
|
14
|
+
|
|
12
15
|
_DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\Z")
|
|
13
16
|
_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
17
|
_ERRORS = {
|
|
19
18
|
"missing_credits", "invalid_credits", "account_mismatch", "unauthenticated",
|
|
20
19
|
"usage_read_failed", "invalid_usage", "http_401", "http_403", "http_429", "http_error",
|
|
21
20
|
"usage_reset_pending",
|
|
22
|
-
}
|
|
21
|
+
} | TRANSPORT_ERRORS
|
|
23
22
|
|
|
24
23
|
|
|
25
24
|
def _iso(value: float) -> str:
|
|
@@ -47,39 +46,24 @@ def _optional_bool(value):
|
|
|
47
46
|
return value if type(value) is bool else None
|
|
48
47
|
|
|
49
48
|
|
|
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
49
|
def snapshot_from_usage(usage: dict, account_id: str, now: float) -> dict:
|
|
59
50
|
"""Bind the provider's wallet and admission facts to the authenticated account."""
|
|
60
51
|
if not isinstance(usage, dict):
|
|
61
52
|
raise ValueError("invalid_usage")
|
|
62
53
|
root = usage.get("rateLimits")
|
|
63
54
|
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
55
|
response_id = usage.get("account_id", usage.get("accountId"))
|
|
71
56
|
expected, reported = _identity(account_id), _identity(response_id)
|
|
72
57
|
if response_id is not None and not reported:
|
|
73
58
|
raise ValueError("account_mismatch")
|
|
74
59
|
if expected and reported and expected != reported:
|
|
75
60
|
raise ValueError("account_mismatch")
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
spend_reached = root.get("spend_control_reached", root.get("spendControlReached", spend.get("reached")))
|
|
61
|
+
raw = root.get("credits")
|
|
62
|
+
if not isinstance(raw, dict):
|
|
63
|
+
raise ValueError("missing_credits")
|
|
64
|
+
has_credits = raw.get("has_credits", raw.get("hasCredits"))
|
|
65
|
+
if type(has_credits) is not bool or type(raw.get("unlimited")) is not bool:
|
|
66
|
+
raise ValueError("invalid_credits")
|
|
83
67
|
try:
|
|
84
68
|
balance = _balance(raw.get("balance"))
|
|
85
69
|
except ValueError as error:
|
|
@@ -87,9 +71,7 @@ def snapshot_from_usage(usage: dict, account_id: str, now: float) -> dict:
|
|
|
87
71
|
return {
|
|
88
72
|
"has_credits": has_credits, "unlimited": raw["unlimited"], "balance": balance,
|
|
89
73
|
"fetched_at": _iso(now), "account_id": reported or expected,
|
|
90
|
-
|
|
91
|
-
"spend_control_reached": _optional_bool(spend_reached),
|
|
92
|
-
"limit_reached_type": _limit_type(reached),
|
|
74
|
+
**_controls(usage, root),
|
|
93
75
|
}
|
|
94
76
|
|
|
95
77
|
|
|
@@ -132,9 +114,11 @@ def credit_view(value, now: float | None = None, stale_after: int = 900) -> dict
|
|
|
132
114
|
|
|
133
115
|
|
|
134
116
|
def failed_credits(previous, error: str, now: float) -> dict | None:
|
|
135
|
-
"""
|
|
117
|
+
"""Keep the last measured amount and its age when a telemetry read fails."""
|
|
136
118
|
out = credit_view(previous, now)
|
|
137
119
|
if out is not None:
|
|
120
|
+
if out.get("last_error") in ("account_mismatch", "unauthenticated"):
|
|
121
|
+
return out
|
|
138
122
|
out.update(last_error=error if error in _ERRORS else "usage_read_failed",
|
|
139
123
|
last_error_at=_iso(now), stale=True)
|
|
140
124
|
return out
|
|
@@ -142,9 +126,21 @@ def failed_credits(previous, error: str, now: float) -> dict | None:
|
|
|
142
126
|
|
|
143
127
|
def collect_credits(usage, account_id: str, now: float, previous=None) -> dict | None:
|
|
144
128
|
try:
|
|
145
|
-
|
|
129
|
+
result = snapshot_from_usage(usage, account_id, now)
|
|
130
|
+
if previous and result["has_credits"] and not result["unlimited"] and result["balance"] is None:
|
|
131
|
+
raise ValueError("invalid_credits")
|
|
132
|
+
return result
|
|
146
133
|
except ValueError as error:
|
|
147
|
-
|
|
134
|
+
result = failed_credits(previous, str(error), now)
|
|
135
|
+
if result is not None and str(error) in ("missing_credits", "invalid_credits"):
|
|
136
|
+
root = usage.get("rateLimits")
|
|
137
|
+
root = root if isinstance(root, dict) else usage
|
|
138
|
+
controls = _controls(usage, root)
|
|
139
|
+
if controls["spend_control_reached"] is True:
|
|
140
|
+
result["spend_control_reached"] = True
|
|
141
|
+
if controls["limit_reached_type"] not in (None, "rate_limit_reached"):
|
|
142
|
+
result["limit_reached_type"] = controls["limit_reached_type"]
|
|
143
|
+
return result
|
|
148
144
|
|
|
149
145
|
|
|
150
146
|
def record_read_failure(path: str, previous: dict, error: str, now: float) -> None:
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Keep a live Codex usage collector's lock even when a slow pass exceeds two minutes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def process_birth(pid: int) -> str:
|
|
16
|
+
try:
|
|
17
|
+
os.kill(pid, 0)
|
|
18
|
+
result = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="],
|
|
19
|
+
capture_output=True, text=True, timeout=5, check=False)
|
|
20
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
21
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
22
|
+
return ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def change_lock(path: Path, pid: int, release: bool = False) -> bool:
|
|
26
|
+
"""Serialize ownership changes; the owner process lifetime governs the lease."""
|
|
27
|
+
birth = process_birth(pid)
|
|
28
|
+
if not birth:
|
|
29
|
+
return False
|
|
30
|
+
with path.with_suffix(".guard").open("a") as guard:
|
|
31
|
+
fcntl.flock(guard, fcntl.LOCK_EX)
|
|
32
|
+
owner = {}
|
|
33
|
+
try:
|
|
34
|
+
owner = json.loads((path / "owner.json").read_text())
|
|
35
|
+
except (OSError, ValueError):
|
|
36
|
+
pass
|
|
37
|
+
ours = owner == {"pid": pid, "birth": birth}
|
|
38
|
+
if release:
|
|
39
|
+
if ours:
|
|
40
|
+
shutil.rmtree(path)
|
|
41
|
+
return ours
|
|
42
|
+
if path.exists():
|
|
43
|
+
owner_pid = owner.get("pid") if isinstance(owner, dict) else None
|
|
44
|
+
if isinstance(owner_pid, int) and owner_pid > 0:
|
|
45
|
+
if owner.get("birth") and process_birth(owner_pid) == owner["birth"]:
|
|
46
|
+
return False
|
|
47
|
+
elif time.time() - path.stat().st_mtime < 120:
|
|
48
|
+
return False
|
|
49
|
+
shutil.rmtree(path)
|
|
50
|
+
path.mkdir()
|
|
51
|
+
(path / "owner.json").write_text(json.dumps({"pid": pid, "birth": birth}))
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
sys.exit(0 if change_lock(Path(sys.argv[2]), int(sys.argv[3]), sys.argv[1] == "release") else 1)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Read-only usage transport with bounded retries and non-secret failure categories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from email.utils import parsedate_to_datetime
|
|
6
|
+
import errno
|
|
7
|
+
import json
|
|
8
|
+
import socket
|
|
9
|
+
import ssl
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
TRANSPORT_ERRORS = {
|
|
15
|
+
"network_unreachable", "dns_unavailable", "usage_timeout", "connection_reset",
|
|
16
|
+
"tls_error", "edge_challenge",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def failure_kind(error: Exception) -> str:
|
|
21
|
+
"""Classify the exception without retaining URLs, headers, bodies, or tokens."""
|
|
22
|
+
if isinstance(error, urllib.error.HTTPError):
|
|
23
|
+
if ((error.headers or {}).get("cf-mitigated") or "").lower() == "challenge":
|
|
24
|
+
return "edge_challenge"
|
|
25
|
+
return f"http_{error.code}" if error.code in (401, 403, 429) else "http_error"
|
|
26
|
+
reason = error.reason if isinstance(error, urllib.error.URLError) else error
|
|
27
|
+
if isinstance(reason, socket.gaierror):
|
|
28
|
+
return "dns_unavailable"
|
|
29
|
+
if isinstance(reason, (socket.timeout, TimeoutError)):
|
|
30
|
+
return "usage_timeout"
|
|
31
|
+
if isinstance(reason, ssl.SSLError):
|
|
32
|
+
return "tls_error"
|
|
33
|
+
if isinstance(reason, OSError):
|
|
34
|
+
if reason.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ENETDOWN):
|
|
35
|
+
return "network_unreachable"
|
|
36
|
+
if reason.errno in (errno.ECONNRESET, errno.ECONNREFUSED, errno.EPIPE):
|
|
37
|
+
return "connection_reset"
|
|
38
|
+
if isinstance(error, (ValueError, UnicodeError)):
|
|
39
|
+
return "invalid_usage"
|
|
40
|
+
return "usage_read_failed"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def retryable(error: Exception) -> bool:
|
|
44
|
+
if isinstance(error, urllib.error.HTTPError):
|
|
45
|
+
return error.code in (500, 502, 503, 504) and not (error.headers or {}).get("Retry-After")
|
|
46
|
+
return failure_kind(error) in TRANSPORT_ERRORS - {"tls_error", "edge_challenge"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fetch_usage(request, budget: float = 15):
|
|
50
|
+
"""Retry once within a monotonic budget; OS DNS/socket operations retain their own timeouts."""
|
|
51
|
+
deadline = time.monotonic() + budget
|
|
52
|
+
for attempt in range(2):
|
|
53
|
+
remaining = max(0.1, deadline - time.monotonic())
|
|
54
|
+
try:
|
|
55
|
+
with urllib.request.urlopen(request, timeout=min(10, remaining)) as response:
|
|
56
|
+
result = json.loads(response.read().decode())
|
|
57
|
+
if not isinstance(result, dict):
|
|
58
|
+
raise ValueError("invalid usage object")
|
|
59
|
+
return result
|
|
60
|
+
except Exception as error:
|
|
61
|
+
if isinstance(error, urllib.error.HTTPError):
|
|
62
|
+
error.close()
|
|
63
|
+
if attempt or not retryable(error) or time.monotonic() >= deadline:
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def retry_delay(error: Exception, previous: dict, now: float) -> int:
|
|
68
|
+
"""Honor Retry-After; otherwise back off transient failures without hammering an account."""
|
|
69
|
+
header = (error.headers or {}).get("Retry-After") if isinstance(error, urllib.error.HTTPError) else None
|
|
70
|
+
if header:
|
|
71
|
+
try:
|
|
72
|
+
delay = int(header)
|
|
73
|
+
except (TypeError, ValueError):
|
|
74
|
+
try:
|
|
75
|
+
delay = int(parsedate_to_datetime(header).timestamp() - now)
|
|
76
|
+
except (TypeError, ValueError, OverflowError):
|
|
77
|
+
delay = 0
|
|
78
|
+
if delay > 0:
|
|
79
|
+
return delay
|
|
80
|
+
try:
|
|
81
|
+
prior = int(previous.get("backoff", 60))
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
prior = 60
|
|
84
|
+
ceiling = 1800 if failure_kind(error) == "http_429" else 900
|
|
85
|
+
return min(ceiling, max(120, prior * 2))
|
package/lib/common.sh
CHANGED
|
@@ -781,8 +781,14 @@ PYEOF
|
|
|
781
781
|
if [ ! -e "$d/CLAUDE.md" ] && [ -f "$HOME/.claude/CLAUDE.md" ]; then
|
|
782
782
|
ln -s "$HOME/.claude/CLAUDE.md" "$d/CLAUDE.md" 2>/dev/null || true
|
|
783
783
|
fi
|
|
784
|
-
|
|
785
|
-
|
|
784
|
+
# A fresh machine may seed pooled accounts before stock Claude has ever created
|
|
785
|
+
# ~/.claude/projects. Create the shared tree first or each account will create
|
|
786
|
+
# its own private history, making --resume depend on the next random pick.
|
|
787
|
+
if [ ! -e "$d/projects" ] && [ ! -L "$d/projects" ]; then
|
|
788
|
+
mkdir -p -m 700 "$HOME/.claude" "$HOME/.claude/projects" 2>/dev/null || true
|
|
789
|
+
if [ "$d" != "$HOME/.claude" ] && [ -d "$HOME/.claude/projects" ]; then
|
|
790
|
+
ln -s "$HOME/.claude/projects" "$d/projects" 2>/dev/null || true
|
|
791
|
+
fi
|
|
786
792
|
fi
|
|
787
793
|
seed_mcp_registry "$d"
|
|
788
794
|
}
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.53",
|
|
4
4
|
"description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"test": "npm run test:core && npm run test:seeding && npm run test:commands",
|
|
59
59
|
"test:core": "bash tests/run-tests.sh && npm run test:selector && python3 tests/test_shim_path.py",
|
|
60
60
|
"test:selector": "python3 -m unittest discover -s tests -p 'test_selector*.py'",
|
|
61
|
-
"test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_mcp_registry.py && npm run test:codex-settings",
|
|
61
|
+
"test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3 tests/test_mcp_registry.py && npm run test:codex-settings",
|
|
62
62
|
"test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
|
|
63
63
|
"test:commands": "python3 tests/test_packaged_commands.py"
|
|
64
64
|
},
|
|
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
|
|
@@ -87,6 +87,19 @@ class AccountSeedingTests(unittest.TestCase):
|
|
|
87
87
|
self.seed(provider)
|
|
88
88
|
self.assertEqual((account / name).read_text(), original)
|
|
89
89
|
|
|
90
|
+
def test_claude_history_is_shared_even_before_stock_claude_runs(self):
|
|
91
|
+
shared = self.home / ".claude" / "projects"
|
|
92
|
+
self.assertFalse(shared.exists())
|
|
93
|
+
first = self.seed("claude")
|
|
94
|
+
second = self.seed("claude", self.home / ".claude-accounts" / "acct-02")
|
|
95
|
+
self.assertTrue(shared.is_dir())
|
|
96
|
+
self.assertEqual((first / "projects").resolve(), shared.resolve())
|
|
97
|
+
self.assertEqual((second / "projects").resolve(), shared.resolve())
|
|
98
|
+
session = first / "projects" / "-work" / "session.jsonl"
|
|
99
|
+
session.parent.mkdir()
|
|
100
|
+
session.write_text("session\n")
|
|
101
|
+
self.assertEqual((second / "projects" / "-work" / "session.jsonl").read_text(), "session\n")
|
|
102
|
+
|
|
90
103
|
def test_local_sync_repairs_only_manifest_accounts(self):
|
|
91
104
|
for provider, name, _ in PROVIDERS:
|
|
92
105
|
with self.subTest(provider=provider):
|