claude-dev-env 8.45.1 → 8.46.1
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/docs/account-broker.md
CHANGED
|
@@ -27,6 +27,7 @@ The broker stores cached meters, spent marks, and Claude session bindings in one
|
|
|
27
27
|
| `0` | `choose` or `check` found an account, or the job exited successfully. |
|
|
28
28
|
| `3` | No account clears its floor. The decision includes `resets_at`. |
|
|
29
29
|
| `4` | A Claude job stopped with `advisor_blocked`. |
|
|
30
|
+
| `127` | A Codex job timed out, or its command could not start while no Codex roster is configured. |
|
|
30
31
|
| Other command code | `run` returns the command's exit code after a served response. |
|
|
31
32
|
|
|
32
33
|
`run` writes a JSON report with `product`, `command`, `events`, and `final_decision`. A wait report has `final_decision.action` equal to `wait` and a UTC `final_decision.resets_at`.
|
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,
|
|
@@ -262,7 +263,8 @@ def _attempt_once(context: _RunContext, decision: Decision) -> JobOutcome | None
|
|
|
262
263
|
_record_spent_attempt(context, account, status, COMMAND_MISSING_EXIT_CODE)
|
|
263
264
|
if isinstance(error, subprocess.TimeoutExpired) or not context.all_readings:
|
|
264
265
|
context.report.final_decision = decision
|
|
265
|
-
|
|
266
|
+
final_status = "advisor_blocked" if context.product is Product.CLAUDE else status
|
|
267
|
+
return JobOutcome(COMMAND_MISSING_EXIT_CODE, "", str(error), account.name, tuple(context.all_attempts), final_status, None, None)
|
|
266
268
|
return None
|
|
267
269
|
combined = f"{completion.stdout}{completion.stderr}".casefold()
|
|
268
270
|
is_limited = completion.returncode != 0 and any(
|
|
@@ -310,11 +312,14 @@ def run_job(
|
|
|
310
312
|
def _parser() -> argparse.ArgumentParser:
|
|
311
313
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
312
314
|
commands = parser.add_subparsers(dest="action", required=True)
|
|
313
|
-
for each_action in ("choose", "check", "accounts", "run"):
|
|
315
|
+
for each_action in ("choose", "check", "accounts", "limits", "run"):
|
|
314
316
|
command = commands.add_parser(each_action)
|
|
315
317
|
command.add_argument("--product", choices=[each_product.value for each_product in Product], required=True)
|
|
316
318
|
if each_action == "choose":
|
|
317
319
|
command.add_argument("--spent", action="append", default=[])
|
|
320
|
+
if each_action == "limits":
|
|
321
|
+
command.add_argument("--home", type=Path, required=True)
|
|
322
|
+
command.add_argument("--codex", type=Path)
|
|
318
323
|
if each_action == "run":
|
|
319
324
|
command.add_argument("--report", type=Path, required=True)
|
|
320
325
|
command.add_argument("command", nargs=argparse.REMAINDER)
|
|
@@ -344,6 +349,19 @@ def _accounts_cli(product: Product) -> int:
|
|
|
344
349
|
return 0
|
|
345
350
|
|
|
346
351
|
|
|
352
|
+
def _limits_cli(product: Product, parsed: argparse.Namespace) -> int:
|
|
353
|
+
if product is not Product.CODEX:
|
|
354
|
+
raise BrokerConfigurationError("limits reads Codex accounts only")
|
|
355
|
+
try:
|
|
356
|
+
rate_limit_records = codex_account_meters.read_rate_limit_records(
|
|
357
|
+
codex_account_meters.resolve_codex_path(parsed.codex), parsed.home
|
|
358
|
+
)
|
|
359
|
+
except (codex_account_meters.CodexMeterUnreadError, OSError) as error:
|
|
360
|
+
raise BrokerConfigurationError(str(error)) from error
|
|
361
|
+
print(json.dumps({"home": str(parsed.home), "rate_limits": rate_limit_records}))
|
|
362
|
+
return 0
|
|
363
|
+
|
|
364
|
+
|
|
347
365
|
def _run_cli(product: Product, parsed: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
348
366
|
command = list(parsed.command)
|
|
349
367
|
if command and command[0] == "--":
|
|
@@ -387,6 +405,8 @@ def main(all_arguments: Sequence[str]) -> int:
|
|
|
387
405
|
try:
|
|
388
406
|
if parsed.action == "accounts":
|
|
389
407
|
return _accounts_cli(product)
|
|
408
|
+
if parsed.action == "limits":
|
|
409
|
+
return _limits_cli(product, parsed)
|
|
390
410
|
if parsed.action == "run":
|
|
391
411
|
return _run_cli(product, parsed, parser)
|
|
392
412
|
return _choose_cli(product, parsed)
|
|
@@ -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
|
|
|
@@ -435,13 +435,44 @@ def test_should_stop_after_timeout_without_running_job_again(
|
|
|
435
435
|
|
|
436
436
|
assert invoked_homes == ["first"]
|
|
437
437
|
assert outcome.attempts == (("first", "timeout"),)
|
|
438
|
-
assert outcome.status == "
|
|
438
|
+
assert outcome.status == "timeout"
|
|
439
439
|
assert outcome.account_name == "first"
|
|
440
440
|
assert report.final_decision.account.name == "first"
|
|
441
441
|
assert account_broker.main(("choose", "--product", "codex")) == 0
|
|
442
442
|
assert json.loads(capsys.readouterr().out)["decision"]["account"] == "first"
|
|
443
443
|
|
|
444
444
|
|
|
445
|
+
@pytest.mark.parametrize(
|
|
446
|
+
("product", "roster_names", "start_error", "expected_code"),
|
|
447
|
+
(
|
|
448
|
+
(Product.CLAUDE, ("first",), subprocess.TimeoutExpired("job", 1), 4),
|
|
449
|
+
(Product.CODEX, ("first",), subprocess.TimeoutExpired("job", 1), 127),
|
|
450
|
+
(Product.CODEX, (), OSError("missing command"), 127),
|
|
451
|
+
),
|
|
452
|
+
)
|
|
453
|
+
def test_should_exit_four_only_for_a_blocked_claude_job(
|
|
454
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
455
|
+
tmp_path: Path,
|
|
456
|
+
product: Product,
|
|
457
|
+
roster_names: tuple[str, ...],
|
|
458
|
+
start_error: Exception,
|
|
459
|
+
expected_code: int,
|
|
460
|
+
) -> None:
|
|
461
|
+
accounts = tuple(_account(each_name, product) for each_name in roster_names)
|
|
462
|
+
monkeypatch.setitem(account_broker.all_product_adapters, product, _adapter(accounts, {
|
|
463
|
+
each_name: _meters(80, 80) for each_name in roster_names
|
|
464
|
+
}))
|
|
465
|
+
monkeypatch.setattr(account_broker.sys, "stdin", io.TextIOWrapper(io.BytesIO(b""), encoding="utf-8"))
|
|
466
|
+
|
|
467
|
+
def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
|
|
468
|
+
raise start_error
|
|
469
|
+
|
|
470
|
+
with account_broker.override_subprocess_runner(runner):
|
|
471
|
+
code = account_broker.main(("run", "--product", product.value, "--report", str(tmp_path / "report.json"), "--", "job"))
|
|
472
|
+
|
|
473
|
+
assert code == expected_code
|
|
474
|
+
|
|
475
|
+
|
|
445
476
|
def test_should_keep_usage_limited_account_spent_for_later_choices(
|
|
446
477
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
447
478
|
) -> None:
|
|
@@ -663,3 +694,41 @@ def test_should_run_job_through_override(
|
|
|
663
694
|
assert outcome.status == "served"
|
|
664
695
|
assert outcome.attempts == (("only", "served"),)
|
|
665
696
|
assert calls == [b"input"]
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def test_should_print_the_whole_rate_limit_result_for_one_home(
|
|
701
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
702
|
+
) -> None:
|
|
703
|
+
rate_limit_records = {"rateLimitsByLimitId": {"codex": {"primary": {"usedPercent": 20}}}}
|
|
704
|
+
observed: list[tuple[Path, Path]] = []
|
|
705
|
+
|
|
706
|
+
def read_result(codex_path: Path, codex_home: Path) -> dict[str, object]:
|
|
707
|
+
observed.append((codex_path, codex_home))
|
|
708
|
+
return rate_limit_records
|
|
709
|
+
|
|
710
|
+
monkeypatch.setattr(account_broker.codex_account_meters, "read_rate_limit_records", read_result)
|
|
711
|
+
home = tmp_path / "codex-2"
|
|
712
|
+
|
|
713
|
+
assert account_broker.main(("limits", "--product", "codex", "--home", str(home), "--codex", "codex-bin")) == 0
|
|
714
|
+
assert json.loads(capsys.readouterr().out) == {"home": str(home), "rate_limits": rate_limit_records}
|
|
715
|
+
assert observed == [(Path("codex-bin"), home)]
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def test_should_exit_two_when_the_limits_read_fails(
|
|
719
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
720
|
+
) -> None:
|
|
721
|
+
def read_result(_codex_path: Path, _codex_home: Path) -> dict[str, object]:
|
|
722
|
+
raise account_broker.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
|
|
723
|
+
|
|
724
|
+
monkeypatch.setattr(account_broker.codex_account_meters, "read_rate_limit_records", read_result)
|
|
725
|
+
|
|
726
|
+
assert account_broker.main(("limits", "--product", "codex", "--home", str(tmp_path), "--codex", "codex-bin")) == 2
|
|
727
|
+
captured = capsys.readouterr()
|
|
728
|
+
assert captured.out == ""
|
|
729
|
+
assert "no rate-limit reply" in captured.err
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def test_should_refuse_limits_for_claude(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
733
|
+
assert account_broker.main(("limits", "--product", "claude", "--home", str(tmp_path))) == 2
|
|
734
|
+
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:
|