claude-dev-env 8.45.0 → 8.46.0
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/package.json +1 -1
- package/scripts/account_broker.py +20 -1
- package/scripts/claude_account_worker_process.py +1 -1
- package/scripts/claude_account_worker_report.py +2 -1
- package/scripts/codex_account_meters.py +41 -3
- package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +0 -15
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +1 -290
- package/scripts/test_account_broker.py +38 -0
- package/scripts/test_codex_account_meters.py +33 -0
package/package.json
CHANGED
|
@@ -14,6 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
from typing import Mapping, Sequence
|
|
15
15
|
|
|
16
16
|
import account_broker_support as support
|
|
17
|
+
import codex_account_meters
|
|
17
18
|
from account_broker_support import (
|
|
18
19
|
all_product_adapters,
|
|
19
20
|
Account,
|
|
@@ -310,11 +311,14 @@ def run_job(
|
|
|
310
311
|
def _parser() -> argparse.ArgumentParser:
|
|
311
312
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
312
313
|
commands = parser.add_subparsers(dest="action", required=True)
|
|
313
|
-
for each_action in ("choose", "check", "accounts", "run"):
|
|
314
|
+
for each_action in ("choose", "check", "accounts", "limits", "run"):
|
|
314
315
|
command = commands.add_parser(each_action)
|
|
315
316
|
command.add_argument("--product", choices=[each_product.value for each_product in Product], required=True)
|
|
316
317
|
if each_action == "choose":
|
|
317
318
|
command.add_argument("--spent", action="append", default=[])
|
|
319
|
+
if each_action == "limits":
|
|
320
|
+
command.add_argument("--home", type=Path, required=True)
|
|
321
|
+
command.add_argument("--codex", type=Path)
|
|
318
322
|
if each_action == "run":
|
|
319
323
|
command.add_argument("--report", type=Path, required=True)
|
|
320
324
|
command.add_argument("command", nargs=argparse.REMAINDER)
|
|
@@ -344,6 +348,19 @@ def _accounts_cli(product: Product) -> int:
|
|
|
344
348
|
return 0
|
|
345
349
|
|
|
346
350
|
|
|
351
|
+
def _limits_cli(product: Product, parsed: argparse.Namespace) -> int:
|
|
352
|
+
if product is not Product.CODEX:
|
|
353
|
+
raise BrokerConfigurationError("limits reads Codex accounts only")
|
|
354
|
+
try:
|
|
355
|
+
rate_limit_records = codex_account_meters.read_rate_limit_records(
|
|
356
|
+
codex_account_meters.resolve_codex_path(parsed.codex), parsed.home
|
|
357
|
+
)
|
|
358
|
+
except (codex_account_meters.CodexMeterUnreadError, OSError) as error:
|
|
359
|
+
raise BrokerConfigurationError(str(error)) from error
|
|
360
|
+
print(json.dumps({"home": str(parsed.home), "rate_limits": rate_limit_records}))
|
|
361
|
+
return 0
|
|
362
|
+
|
|
363
|
+
|
|
347
364
|
def _run_cli(product: Product, parsed: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
348
365
|
command = list(parsed.command)
|
|
349
366
|
if command and command[0] == "--":
|
|
@@ -387,6 +404,8 @@ def main(all_arguments: Sequence[str]) -> int:
|
|
|
387
404
|
try:
|
|
388
405
|
if parsed.action == "accounts":
|
|
389
406
|
return _accounts_cli(product)
|
|
407
|
+
if parsed.action == "limits":
|
|
408
|
+
return _limits_cli(product, parsed)
|
|
390
409
|
if parsed.action == "run":
|
|
391
410
|
return _run_cli(product, parsed, parser)
|
|
392
411
|
return _choose_cli(product, parsed)
|
|
@@ -57,6 +57,7 @@ def wait_report(account: str, reason: str, wait_reset_at: datetime | None) -> Wo
|
|
|
57
57
|
Args:
|
|
58
58
|
account: The chosen account slot.
|
|
59
59
|
reason: Why the broker returned a wait decision.
|
|
60
|
+
wait_reset_at: The soonest account reset, or None when unknown.
|
|
60
61
|
|
|
61
62
|
Returns:
|
|
62
63
|
A report carrying the wait exit code and no payload.
|
|
@@ -111,7 +112,7 @@ def pre_launch_failure_report(
|
|
|
111
112
|
|
|
112
113
|
Args:
|
|
113
114
|
account: The chosen account slot.
|
|
114
|
-
reason: Why the
|
|
115
|
+
reason: Why the worker stopped before launch.
|
|
115
116
|
exit_code: The failure's exit code.
|
|
116
117
|
|
|
117
118
|
Returns:
|
|
@@ -161,7 +161,7 @@ def _parse_window(raw_window: object) -> UsageWindow | None:
|
|
|
161
161
|
)
|
|
162
162
|
|
|
163
163
|
|
|
164
|
-
def
|
|
164
|
+
def _reply_body(reply_text: str) -> Mapping[str, object]:
|
|
165
165
|
try:
|
|
166
166
|
reply = json.loads(reply_text)
|
|
167
167
|
except json.JSONDecodeError as error:
|
|
@@ -171,7 +171,13 @@ def _rate_limits_mapping(reply_text: str) -> Mapping[str, object]:
|
|
|
171
171
|
if reply.get("error") is not None:
|
|
172
172
|
raise CodexMeterUnreadError(f"rate-limit read failed: {reply['error']}")
|
|
173
173
|
body = reply.get("result")
|
|
174
|
-
|
|
174
|
+
if not isinstance(body, dict):
|
|
175
|
+
raise CodexMeterUnreadError("rate-limit reply has no result")
|
|
176
|
+
return body
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _rate_limits_mapping(reply_text: str) -> Mapping[str, object]:
|
|
180
|
+
rate_limits = _reply_body(reply_text).get("rateLimits")
|
|
175
181
|
if not isinstance(rate_limits, dict):
|
|
176
182
|
raise CodexMeterUnreadError("rate-limit reply names no rateLimits")
|
|
177
183
|
return rate_limits
|
|
@@ -338,13 +344,45 @@ def read_codex_meters(
|
|
|
338
344
|
Raises:
|
|
339
345
|
CodexMeterUnreadError: The server failed, timed out, or sent no usable reply.
|
|
340
346
|
"""
|
|
347
|
+
return parse_rate_limits_reply(_rate_limits_reply_line(codex_path, codex_home, exchange))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def read_rate_limit_records(
|
|
351
|
+
codex_path: Path,
|
|
352
|
+
codex_home: Path,
|
|
353
|
+
exchange: ServerExchange = exchange_with_app_server,
|
|
354
|
+
) -> Mapping[str, object]:
|
|
355
|
+
"""Read one account's whole rate-limit reply through the app server.
|
|
356
|
+
|
|
357
|
+
::
|
|
358
|
+
|
|
359
|
+
{"id": 2, "result": {"rateLimits": {...}, "rateLimitsByLimitId": {...}}}
|
|
360
|
+
-> {"rateLimits": {...}, "rateLimitsByLimitId": {...}}
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
codex_path: The Codex executable.
|
|
364
|
+
codex_home: The account's Codex home.
|
|
365
|
+
exchange: Sends messages and returns output lines; tests pass a fake.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
The reply's ``result`` object, with every limit record it carries.
|
|
369
|
+
|
|
370
|
+
Raises:
|
|
371
|
+
CodexMeterUnreadError: The server failed, timed out, or sent no usable reply.
|
|
372
|
+
"""
|
|
373
|
+
return _reply_body(_rate_limits_reply_line(codex_path, codex_home, exchange))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _rate_limits_reply_line(
|
|
377
|
+
codex_path: Path, codex_home: Path, exchange: ServerExchange
|
|
378
|
+
) -> str:
|
|
341
379
|
try:
|
|
342
380
|
all_lines = exchange(codex_path, codex_home, request_messages())
|
|
343
381
|
except (OSError, subprocess.SubprocessError, UnicodeDecodeError) as error:
|
|
344
382
|
raise CodexMeterUnreadError(f"codex app-server failed: {error}") from error
|
|
345
383
|
for each_line in all_lines:
|
|
346
384
|
if _is_rate_limits_reply(each_line):
|
|
347
|
-
return
|
|
385
|
+
return each_line
|
|
348
386
|
raise CodexMeterUnreadError("codex app-server sent no rate-limit reply")
|
|
349
387
|
|
|
350
388
|
|
|
@@ -11,10 +11,6 @@ DEFAULT_TIMEOUT_MINUTES: int = 60
|
|
|
11
11
|
|
|
12
12
|
MINIMUM_TIMEOUT_MINUTES: int = 1
|
|
13
13
|
|
|
14
|
-
TIMEOUT_EXIT_CODE: int = 124
|
|
15
|
-
|
|
16
|
-
MISSING_BINARY_EXIT_CODE: int = 127
|
|
17
|
-
|
|
18
14
|
LAUNCH_FAILURE_EXIT_CODE: int = 1
|
|
19
15
|
|
|
20
16
|
CONFIGURATION_FAILURE_EXIT_CODE: int = 2
|
|
@@ -23,18 +19,12 @@ WAIT_DURATION_SECONDS: float = 0.0
|
|
|
23
19
|
|
|
24
20
|
STDOUT_TAIL_CHARACTER_LIMIT: int = 4000
|
|
25
21
|
|
|
26
|
-
DRAIN_GRACE_TIMEOUT_SECONDS: int = 10
|
|
27
|
-
|
|
28
|
-
DRAIN_ATTEMPT_LIMIT: int = 2
|
|
29
|
-
|
|
30
22
|
DURATION_DECIMAL_PLACES: int = 3
|
|
31
23
|
|
|
32
24
|
REPORT_INDENT: int = 2
|
|
33
25
|
|
|
34
26
|
UTF8_ENCODING: str = "utf-8"
|
|
35
27
|
|
|
36
|
-
UTF8_DECODE_ERRORS: str = "replace"
|
|
37
|
-
|
|
38
28
|
CLI_DESCRIPTION: str = "Run one Claude worker through the account broker."
|
|
39
29
|
|
|
40
30
|
PROMPT_FILE_FLAG: str = "--prompt-file"
|
|
@@ -85,8 +75,6 @@ __all__ = [
|
|
|
85
75
|
"CWD_FLAG",
|
|
86
76
|
"DEFAULT_PERMISSION_MODE",
|
|
87
77
|
"DEFAULT_TIMEOUT_MINUTES",
|
|
88
|
-
"DRAIN_ATTEMPT_LIMIT",
|
|
89
|
-
"DRAIN_GRACE_TIMEOUT_SECONDS",
|
|
90
78
|
"DURATION_DECIMAL_PLACES",
|
|
91
79
|
"INVALID_TIMEOUT_MESSAGE",
|
|
92
80
|
"JSON_ACCOUNT_KEY",
|
|
@@ -94,7 +82,6 @@ __all__ = [
|
|
|
94
82
|
"JSON_REASON_KEY",
|
|
95
83
|
"JSON_RESULT_KEY",
|
|
96
84
|
"LAUNCH_FAILURE_EXIT_CODE",
|
|
97
|
-
"MISSING_BINARY_EXIT_CODE",
|
|
98
85
|
"MINIMUM_TIMEOUT_MINUTES",
|
|
99
86
|
"MODEL_FLAG",
|
|
100
87
|
"OUTPUT_FORMAT_FLAG",
|
|
@@ -113,9 +100,7 @@ __all__ = [
|
|
|
113
100
|
"SINGLE_PROMPT_FLAG",
|
|
114
101
|
"STDOUT_TAIL_CHARACTER_LIMIT",
|
|
115
102
|
"SUMMARY_LINE_TEMPLATE",
|
|
116
|
-
"TIMEOUT_EXIT_CODE",
|
|
117
103
|
"TIMEOUT_MINUTES_FLAG",
|
|
118
|
-
"UTF8_DECODE_ERRORS",
|
|
119
104
|
"UTF8_ENCODING",
|
|
120
105
|
"WAIT_DURATION_SECONDS",
|
|
121
106
|
"WAIT_EXIT_CODE",
|
|
@@ -1,93 +1,7 @@
|
|
|
1
|
-
"""Named constants
|
|
2
|
-
picks which text-codec kwargs a chain subprocess wrapper forwards.
|
|
3
|
-
|
|
4
|
-
Per the project's configuration conventions, every scalar and structural
|
|
5
|
-
constant the runner needs lives here rather than inline in the module.
|
|
6
|
-
"""
|
|
1
|
+
"""Named constants the account broker and the code review CLI share for Claude runs."""
|
|
7
2
|
|
|
8
3
|
from __future__ import annotations
|
|
9
4
|
|
|
10
|
-
from collections.abc import Mapping
|
|
11
|
-
|
|
12
|
-
UTF8_ENCODING: str = "utf-8"
|
|
13
|
-
"""Encoding used to read the chain configuration file."""
|
|
14
|
-
|
|
15
|
-
CODEC_ERROR_STRATEGY: str = "replace"
|
|
16
|
-
"""Codec error handler that maps any unencodable or undecodable value to a marker."""
|
|
17
|
-
|
|
18
|
-
CRLF_NEWLINE: str = "\r\n"
|
|
19
|
-
"""Windows-style newline sequence normalized away when decoding spool captures."""
|
|
20
|
-
|
|
21
|
-
CARRIAGE_RETURN: str = "\r"
|
|
22
|
-
"""Bare carriage return normalized to LF when decoding spool captures."""
|
|
23
|
-
|
|
24
|
-
LINE_FEED: str = "\n"
|
|
25
|
-
"""Unix-style newline retained after universal-newline normalization."""
|
|
26
|
-
|
|
27
|
-
SUBPROCESS_ENCODING_KEYWORD: str = "encoding"
|
|
28
|
-
"""Keyword name for text encoding when forwarding chain subprocess runner kwargs."""
|
|
29
|
-
|
|
30
|
-
SUBPROCESS_ERRORS_KEYWORD: str = "errors"
|
|
31
|
-
"""Keyword name for text decode error policy when forwarding chain subprocess runner kwargs."""
|
|
32
|
-
|
|
33
|
-
ALL_SUBPROCESS_TEXT_CODEC_KEYWORDS: tuple[str, ...] = (
|
|
34
|
-
SUBPROCESS_ENCODING_KEYWORD,
|
|
35
|
-
SUBPROCESS_ERRORS_KEYWORD,
|
|
36
|
-
)
|
|
37
|
-
"""Keyword names to forward from the chain runner for text-mode subprocess capture."""
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def collect_forwarded_text_codec(
|
|
41
|
-
all_keywords: Mapping[str, object],
|
|
42
|
-
) -> dict[str, object]:
|
|
43
|
-
"""Return only the text-codec kwargs present in ``all_keywords``.
|
|
44
|
-
|
|
45
|
-
::
|
|
46
|
-
|
|
47
|
-
collect_forwarded_text_codec({"encoding": "utf-8", "timeout": 30})
|
|
48
|
-
# -> {"encoding": "utf-8"}
|
|
49
|
-
|
|
50
|
-
collect_forwarded_text_codec({"timeout": 30})
|
|
51
|
-
# -> {}
|
|
52
|
-
|
|
53
|
-
Args:
|
|
54
|
-
all_keywords: Keyword arguments received by a chain subprocess wrapper.
|
|
55
|
-
|
|
56
|
-
Returns:
|
|
57
|
-
Mapping of codec keyword names to their values, limited to keys listed
|
|
58
|
-
in ``ALL_SUBPROCESS_TEXT_CODEC_KEYWORDS`` that are present in the input.
|
|
59
|
-
"""
|
|
60
|
-
return {
|
|
61
|
-
each_key: all_keywords[each_key]
|
|
62
|
-
for each_key in ALL_SUBPROCESS_TEXT_CODEC_KEYWORDS
|
|
63
|
-
if each_key in all_keywords
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
CLAUDE_HOME_SUBDIRECTORY: str = ".claude"
|
|
68
|
-
"""Per-user directory under the home directory that holds the chain config."""
|
|
69
|
-
|
|
70
|
-
CONFIG_FILENAME: str = "claude-chain.json"
|
|
71
|
-
"""Real chain-configuration filename read from the user's home directory."""
|
|
72
|
-
|
|
73
|
-
CHAIN_USAGE_MODULE_NAME: str = "claude_chain_usage"
|
|
74
|
-
"""Import name of the weekly-usage report module loaded lazily by the runner."""
|
|
75
|
-
|
|
76
|
-
EXAMPLE_CONFIG_FILENAME: str = "claude-chain.example.json"
|
|
77
|
-
"""Committed template filename referenced in the config-error guidance."""
|
|
78
|
-
|
|
79
|
-
CONFIG_CHAIN_KEY: str = "chain"
|
|
80
|
-
"""Top-level key whose value is the ordered list of chain entries."""
|
|
81
|
-
|
|
82
|
-
CONFIG_COMMAND_KEY: str = "command"
|
|
83
|
-
"""Chain-entry key naming the binary to spawn."""
|
|
84
|
-
|
|
85
|
-
CONFIG_EXTRA_ARGS_KEY: str = "extra_args"
|
|
86
|
-
"""Chain-entry key holding per-account arguments appended to each invocation."""
|
|
87
|
-
|
|
88
|
-
CONFIG_CREDENTIALS_PATH_KEY: str = "credentials_path"
|
|
89
|
-
"""Optional chain-entry key naming that account's OAuth credentials file path."""
|
|
90
|
-
|
|
91
5
|
ALL_USAGE_LIMIT_SIGNATURES: tuple[str, ...] = (
|
|
92
6
|
"hit your session limit",
|
|
93
7
|
"usage limit reached",
|
|
@@ -96,208 +10,5 @@ ALL_USAGE_LIMIT_SIGNATURES: tuple[str, ...] = (
|
|
|
96
10
|
)
|
|
97
11
|
"""Case-insensitive substrings that mark a non-zero exit as a usage-limit refusal."""
|
|
98
12
|
|
|
99
|
-
ATTEMPT_STATUS_SERVED: str = "served"
|
|
100
|
-
"""Status recorded when a binary exits zero and serves the call."""
|
|
101
|
-
|
|
102
|
-
ATTEMPT_STATUS_USAGE_LIMITED: str = "usage_limited"
|
|
103
|
-
"""Status recorded when a binary fails with a usage-limit signature."""
|
|
104
|
-
|
|
105
|
-
ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND: str = "executable_not_found"
|
|
106
|
-
"""Status recorded when a binary is not installed."""
|
|
107
|
-
|
|
108
|
-
ATTEMPT_STATUS_NONZERO_EXIT: str = "nonzero_exit"
|
|
109
|
-
"""Status recorded when a binary fails without a usage-limit signature."""
|
|
110
|
-
|
|
111
|
-
ATTEMPT_STATUS_TIMEOUT: str = "timeout"
|
|
112
|
-
"""Status recorded when a binary exceeds the invocation timeout."""
|
|
113
|
-
|
|
114
|
-
DEFAULT_TIMEOUT_SECONDS: int = 300
|
|
115
|
-
"""Timeout applied to each binary invocation when the caller names none."""
|
|
116
|
-
|
|
117
|
-
NO_COMPLETED_PROCESS_RETURN_CODE: int = 1
|
|
118
|
-
"""Return code carried on the result when no binary produced a completed process."""
|
|
119
|
-
|
|
120
|
-
CHAIN_EXHAUSTED_EXIT_CODE: int = 2
|
|
121
|
-
"""CLI exit code when no binary in the chain served the call."""
|
|
122
|
-
|
|
123
13
|
CHAIN_CONFIG_ERROR_EXIT_CODE: int = 3
|
|
124
14
|
"""CLI exit code when the chain configuration is missing or invalid."""
|
|
125
|
-
|
|
126
|
-
CLI_TIMEOUT_FLAG: str = "--timeout-seconds"
|
|
127
|
-
"""CLI flag that overrides the per-invocation timeout in seconds."""
|
|
128
|
-
|
|
129
|
-
CLI_ROUTING_MODE_FLAG: str = "--routing-mode"
|
|
130
|
-
"""CLI flag that selects usage-ranked or ordered-account chain routing."""
|
|
131
|
-
|
|
132
|
-
CLI_ARGUMENTS_SEPARATOR: str = "--"
|
|
133
|
-
"""CLI token separating runner flags from the passthrough claude arguments."""
|
|
134
|
-
|
|
135
|
-
ROUTING_MODE_USAGE_RANKED: str = "usage_ranked"
|
|
136
|
-
"""Default routing: probe weekly remaining and try highest remaining first."""
|
|
137
|
-
|
|
138
|
-
ROUTING_MODE_ORDERED_ACCOUNT: str = "ordered_account"
|
|
139
|
-
"""Explicit routing: walk chain entries in config order; usage-limit-only fallover."""
|
|
140
|
-
|
|
141
|
-
DEFAULT_ROUTING_MODE: str = ROUTING_MODE_USAGE_RANKED
|
|
142
|
-
"""Routing mode applied when the caller does not name one."""
|
|
143
|
-
|
|
144
|
-
ALL_ROUTING_MODES: frozenset[str] = frozenset(
|
|
145
|
-
{
|
|
146
|
-
ROUTING_MODE_USAGE_RANKED,
|
|
147
|
-
ROUTING_MODE_ORDERED_ACCOUNT,
|
|
148
|
-
}
|
|
149
|
-
)
|
|
150
|
-
"""Accepted values for the routing-mode parameter and CLI flag."""
|
|
151
|
-
|
|
152
|
-
TERMINAL_STATUS_SERVED: str = "served"
|
|
153
|
-
"""Outcome status when a binary served the call (zero or non-usage nonzero)."""
|
|
154
|
-
|
|
155
|
-
TERMINAL_STATUS_ADVISOR_BLOCKED: str = "advisor_blocked"
|
|
156
|
-
"""Outcome status when ordered-account mode stops on a non-usage failure."""
|
|
157
|
-
|
|
158
|
-
TERMINAL_STATUS_CHAIN_EXHAUSTED: str = "chain_exhausted"
|
|
159
|
-
"""Outcome status when every chain entry was usage-limited or missing."""
|
|
160
|
-
|
|
161
|
-
TERMINAL_STATUS_TIMEOUT: str = "timeout"
|
|
162
|
-
"""Outcome status when a usage-ranked walk stops on TimeoutExpired mid-walk."""
|
|
163
|
-
|
|
164
|
-
SESSION_ID_JSON_KEY: str = "session_id"
|
|
165
|
-
"""JSON key read from Claude ``--output-format json`` events for resume."""
|
|
166
|
-
|
|
167
|
-
CHAIN_ADVISOR_BLOCKED_EXIT_CODE: int = 4
|
|
168
|
-
"""CLI exit code when ordered-account mode stops with advisor_blocked."""
|
|
169
|
-
|
|
170
|
-
CONFIG_NOT_OBJECT_REASON: str = "the top-level value is not a JSON object"
|
|
171
|
-
"""Reason detail when the config root is not an object."""
|
|
172
|
-
|
|
173
|
-
CONFIG_CHAIN_NOT_LIST_REASON: str = "the 'chain' key is missing or not a list"
|
|
174
|
-
"""Reason detail when the chain key is absent or the wrong type."""
|
|
175
|
-
|
|
176
|
-
CONFIG_CHAIN_EMPTY_REASON: str = "the 'chain' list is empty"
|
|
177
|
-
"""Reason detail when the chain contains no entries."""
|
|
178
|
-
|
|
179
|
-
CONFIG_ENTRY_NOT_OBJECT_REASON: str = "a chain entry is not a JSON object"
|
|
180
|
-
"""Reason detail when a chain entry is not an object."""
|
|
181
|
-
|
|
182
|
-
CONFIG_ENTRY_COMMAND_MISSING_REASON: str = "a chain entry has no string 'command'"
|
|
183
|
-
"""Reason detail when a chain entry lacks a usable command."""
|
|
184
|
-
|
|
185
|
-
CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON: str = (
|
|
186
|
-
"a chain entry's 'extra_args' is not a list of strings"
|
|
187
|
-
)
|
|
188
|
-
"""Reason detail when a chain entry's extra_args value is the wrong shape."""
|
|
189
|
-
|
|
190
|
-
CONFIG_ENTRY_CREDENTIALS_PATH_INVALID_REASON: str = (
|
|
191
|
-
"a chain entry's 'credentials_path' is not a non-empty string"
|
|
192
|
-
)
|
|
193
|
-
"""Reason detail when a chain entry's credentials_path value is the wrong shape."""
|
|
194
|
-
|
|
195
|
-
CONFIG_MISSING_MESSAGE_TEMPLATE: str = (
|
|
196
|
-
"Claude chain config not found at {config_path}. Copy {example_filename} to "
|
|
197
|
-
"{config_path} and list your account binaries. Try order comes from weekly "
|
|
198
|
-
"remaining; config order is the tiebreak."
|
|
199
|
-
)
|
|
200
|
-
"""Guidance shown when the config file is absent."""
|
|
201
|
-
|
|
202
|
-
CONFIG_UNREADABLE_MESSAGE_TEMPLATE: str = (
|
|
203
|
-
"Cannot read claude chain config at {config_path}: {error}. "
|
|
204
|
-
"See {example_filename} for the expected shape."
|
|
205
|
-
)
|
|
206
|
-
"""Guidance shown when the config file cannot be read."""
|
|
207
|
-
|
|
208
|
-
CONFIG_MALFORMED_MESSAGE_TEMPLATE: str = (
|
|
209
|
-
"Malformed JSON in claude chain config at {config_path}: {error}. "
|
|
210
|
-
"See {example_filename} for the expected shape."
|
|
211
|
-
)
|
|
212
|
-
"""Guidance shown when the config file is not valid JSON."""
|
|
213
|
-
|
|
214
|
-
CONFIG_INVALID_SHAPE_MESSAGE_TEMPLATE: str = (
|
|
215
|
-
"Invalid claude chain config at {config_path}: {reason}. "
|
|
216
|
-
"See {example_filename} for the expected shape."
|
|
217
|
-
)
|
|
218
|
-
"""Guidance shown when the config JSON does not match the expected shape."""
|
|
219
|
-
|
|
220
|
-
CHAIN_EXHAUSTED_MESSAGE_TEMPLATE: str = (
|
|
221
|
-
"No claude binary in the chain served the call. Attempts: {attempt_summary}"
|
|
222
|
-
)
|
|
223
|
-
"""CLI stderr message when the walk ends without a serving binary."""
|
|
224
|
-
|
|
225
|
-
ATTEMPT_SUMMARY_ENTRY_TEMPLATE: str = "{command}={status}"
|
|
226
|
-
"""Per-attempt fragment used to build the exhausted-chain summary."""
|
|
227
|
-
|
|
228
|
-
ATTEMPT_SUMMARY_JOIN_SEPARATOR: str = ", "
|
|
229
|
-
"""Separator joining per-attempt fragments in the exhausted-chain summary."""
|
|
230
|
-
|
|
231
|
-
AFFINITY_STATE_SCHEMA_VERSION: int = 1
|
|
232
|
-
"""Version field written into the session-to-binary affinity state document."""
|
|
233
|
-
|
|
234
|
-
AFFINITY_STATE_FILENAME: str = "claude-chain-affinity.json"
|
|
235
|
-
"""Default affinity state file name under the Claude home directory."""
|
|
236
|
-
|
|
237
|
-
AFFINITY_MAXIMUM_ENTRIES: int = 64
|
|
238
|
-
"""Hard cap on retained session-to-binary affinity rows (oldest drop first)."""
|
|
239
|
-
|
|
240
|
-
AFFINITY_KEY_SCHEMA_VERSION: str = "schema_version"
|
|
241
|
-
"""JSON key for the affinity document schema version."""
|
|
242
|
-
|
|
243
|
-
AFFINITY_KEY_ALL_BINDINGS: str = "all_bindings"
|
|
244
|
-
"""JSON key for the ordered list of session-to-command bindings."""
|
|
245
|
-
|
|
246
|
-
AFFINITY_KEY_SESSION_ID: str = "session_id"
|
|
247
|
-
"""JSON key for a bound Claude session id."""
|
|
248
|
-
|
|
249
|
-
AFFINITY_KEY_COMMAND: str = "command"
|
|
250
|
-
"""JSON key for the chain binary command bound to a session id."""
|
|
251
|
-
|
|
252
|
-
AFFINITY_TEMP_SUFFIX: str = ".tmp"
|
|
253
|
-
"""Suffix for the temporary file used during atomic affinity replacement."""
|
|
254
|
-
|
|
255
|
-
AFFINITY_BINDING_NOT_OBJECT_REASON: str = "a binding entry is not an object"
|
|
256
|
-
"""Reason when an affinity binding entry is not a JSON object."""
|
|
257
|
-
|
|
258
|
-
AFFINITY_BINDING_SESSION_ID_MISSING_REASON: str = (
|
|
259
|
-
"binding missing non-empty session_id"
|
|
260
|
-
)
|
|
261
|
-
"""Reason when an affinity binding lacks a usable session_id."""
|
|
262
|
-
|
|
263
|
-
AFFINITY_BINDING_COMMAND_MISSING_REASON: str = "binding missing non-empty command"
|
|
264
|
-
"""Reason when an affinity binding lacks a usable command."""
|
|
265
|
-
|
|
266
|
-
AFFINITY_UNSUPPORTED_SCHEMA_VERSION_REASON_TEMPLATE: str = (
|
|
267
|
-
"unsupported schema_version {schema_version!r}"
|
|
268
|
-
)
|
|
269
|
-
"""Reason when the affinity document schema version is not supported."""
|
|
270
|
-
|
|
271
|
-
AFFINITY_BINDINGS_MISSING_OR_NOT_LIST_REASON: str = (
|
|
272
|
-
"all_bindings is missing or not a list"
|
|
273
|
-
)
|
|
274
|
-
"""Reason when all_bindings is absent or the wrong type."""
|
|
275
|
-
|
|
276
|
-
AFFINITY_TOP_LEVEL_NOT_OBJECT_REASON: str = "top-level value is not a JSON object"
|
|
277
|
-
"""Reason when the affinity document root is not an object."""
|
|
278
|
-
|
|
279
|
-
AFFINITY_SESSION_ID_AND_COMMAND_REQUIRED_MESSAGE: str = (
|
|
280
|
-
"session_id and command must be non-empty"
|
|
281
|
-
)
|
|
282
|
-
"""ValueError message when record_affinity_binding receives empty ids."""
|
|
283
|
-
|
|
284
|
-
AFFINITY_MAXIMUM_ENTRIES_MINIMUM_MESSAGE: str = "maximum_entries must be at least 1"
|
|
285
|
-
"""ValueError message when maximum_entries is below one."""
|
|
286
|
-
|
|
287
|
-
AFFINITY_CORRUPT_MESSAGE_TEMPLATE: str = (
|
|
288
|
-
"Affinity state at {state_path} is corrupt or unreadable: {error}. "
|
|
289
|
-
"Delete or repair the file before retrying."
|
|
290
|
-
)
|
|
291
|
-
"""Actionable diagnostic when affinity state cannot be loaded."""
|
|
292
|
-
|
|
293
|
-
AFFINITY_WRITE_FAILED_MESSAGE_TEMPLATE: str = (
|
|
294
|
-
"Failed to write affinity state at {state_path}: {error}. "
|
|
295
|
-
"Check directory permissions and free space."
|
|
296
|
-
)
|
|
297
|
-
"""Actionable diagnostic when atomic affinity replacement fails."""
|
|
298
|
-
|
|
299
|
-
AFFINITY_JSON_INDENT_SPACES: int = 2
|
|
300
|
-
"""Indent width for the written affinity state JSON document."""
|
|
301
|
-
|
|
302
|
-
RESUME_SESSION_FLAG: str = "--resume"
|
|
303
|
-
"""Claude CLI flag that continues a prior session by id."""
|
|
@@ -663,3 +663,41 @@ def test_should_run_job_through_override(
|
|
|
663
663
|
assert outcome.status == "served"
|
|
664
664
|
assert outcome.attempts == (("only", "served"),)
|
|
665
665
|
assert calls == [b"input"]
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def test_should_print_the_whole_rate_limit_result_for_one_home(
|
|
670
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
671
|
+
) -> None:
|
|
672
|
+
rate_limit_records = {"rateLimitsByLimitId": {"codex": {"primary": {"usedPercent": 20}}}}
|
|
673
|
+
observed: list[tuple[Path, Path]] = []
|
|
674
|
+
|
|
675
|
+
def read_result(codex_path: Path, codex_home: Path) -> dict[str, object]:
|
|
676
|
+
observed.append((codex_path, codex_home))
|
|
677
|
+
return rate_limit_records
|
|
678
|
+
|
|
679
|
+
monkeypatch.setattr(account_broker.codex_account_meters, "read_rate_limit_records", read_result)
|
|
680
|
+
home = tmp_path / "codex-2"
|
|
681
|
+
|
|
682
|
+
assert account_broker.main(("limits", "--product", "codex", "--home", str(home), "--codex", "codex-bin")) == 0
|
|
683
|
+
assert json.loads(capsys.readouterr().out) == {"home": str(home), "rate_limits": rate_limit_records}
|
|
684
|
+
assert observed == [(Path("codex-bin"), home)]
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def test_should_exit_two_when_the_limits_read_fails(
|
|
688
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
689
|
+
) -> None:
|
|
690
|
+
def read_result(_codex_path: Path, _codex_home: Path) -> dict[str, object]:
|
|
691
|
+
raise account_broker.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
|
|
692
|
+
|
|
693
|
+
monkeypatch.setattr(account_broker.codex_account_meters, "read_rate_limit_records", read_result)
|
|
694
|
+
|
|
695
|
+
assert account_broker.main(("limits", "--product", "codex", "--home", str(tmp_path), "--codex", "codex-bin")) == 2
|
|
696
|
+
captured = capsys.readouterr()
|
|
697
|
+
assert captured.out == ""
|
|
698
|
+
assert "no rate-limit reply" in captured.err
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def test_should_refuse_limits_for_claude(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
702
|
+
assert account_broker.main(("limits", "--product", "claude", "--home", str(tmp_path))) == 2
|
|
703
|
+
assert "Codex accounts only" in capsys.readouterr().err
|
|
@@ -15,6 +15,7 @@ from codex_account_meters import (
|
|
|
15
15
|
exchange_with_app_server,
|
|
16
16
|
parse_rate_limits_reply,
|
|
17
17
|
read_codex_meters,
|
|
18
|
+
read_rate_limit_records,
|
|
18
19
|
request_messages,
|
|
19
20
|
resolve_codex_path,
|
|
20
21
|
)
|
|
@@ -163,6 +164,38 @@ class TestReadCodexMeters:
|
|
|
163
164
|
read_codex_meters(Path("codex"), Path("/h"), broken_exchange)
|
|
164
165
|
|
|
165
166
|
|
|
167
|
+
|
|
168
|
+
class TestReadRateLimitRecords:
|
|
169
|
+
def should_return_every_limit_record_the_reply_carries(self) -> None:
|
|
170
|
+
rate_limit_records = {
|
|
171
|
+
"rateLimits": {"limitId": "codex", "primary": {"usedPercent": 20}},
|
|
172
|
+
"rateLimitsByLimitId": {
|
|
173
|
+
"codex": {"limitId": "codex", "planType": "pro"},
|
|
174
|
+
"codex_other": {"limitId": "codex_other"},
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
assert (
|
|
179
|
+
read_rate_limit_records(
|
|
180
|
+
Path("codex"),
|
|
181
|
+
Path("/h"),
|
|
182
|
+
lambda *_: [json.dumps({"id": 1}), json.dumps({"id": 2, "result": rate_limit_records})],
|
|
183
|
+
)
|
|
184
|
+
== rate_limit_records
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def should_raise_unread_on_an_error_reply(self) -> None:
|
|
188
|
+
with pytest.raises(CodexMeterUnreadError, match="read failed"):
|
|
189
|
+
read_rate_limit_records(
|
|
190
|
+
Path("codex"),
|
|
191
|
+
Path("/h"),
|
|
192
|
+
lambda *_: [json.dumps({"id": 2, "error": {"code": 1}})],
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def should_raise_unread_when_the_server_never_answers(self) -> None:
|
|
196
|
+
with pytest.raises(CodexMeterUnreadError, match="no rate-limit reply"):
|
|
197
|
+
read_rate_limit_records(Path("codex"), Path("/h"), lambda *_: [])
|
|
198
|
+
|
|
166
199
|
FAKE_APP_SERVER = """#!/usr/bin/env python3
|
|
167
200
|
import json, os, sys
|
|
168
201
|
for line in sys.stdin:
|