claude-dev-env 8.37.3 → 8.38.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/docs/account-broker.md +32 -0
- package/package.json +1 -1
- package/scripts/account_broker.py +387 -0
- package/scripts/account_broker_support.py +377 -0
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +301 -0
- package/scripts/test_account_broker.py +501 -0
- package/scripts/test_account_broker_guard.py +109 -0
- package/scripts/test_account_broker_support.py +107 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import io
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import account_broker
|
|
12
|
+
from account_broker import (
|
|
13
|
+
Account,
|
|
14
|
+
Meters,
|
|
15
|
+
Product,
|
|
16
|
+
ProductAdapter,
|
|
17
|
+
Reading,
|
|
18
|
+
choose_from_readings,
|
|
19
|
+
run_job,
|
|
20
|
+
)
|
|
21
|
+
from dev_env_scripts_constants.account_broker_constants import WAIT_EXIT_CODE
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
NOW = datetime(2026, 10, 3, tzinfo=timezone.utc)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture(autouse=True)
|
|
28
|
+
def isolated_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
29
|
+
monkeypatch.setattr(account_broker, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
30
|
+
monkeypatch.setattr(account_broker.support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _account(name: str, product: Product = Product.CODEX, main: bool = False) -> Account:
|
|
34
|
+
return Account(product, name, Path("/profiles") / name, main, name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _meters(
|
|
38
|
+
short_left: float | None,
|
|
39
|
+
weekly_left: float | None,
|
|
40
|
+
*,
|
|
41
|
+
short_reset: datetime | None = None,
|
|
42
|
+
weekly_reset: datetime | None = None,
|
|
43
|
+
) -> Meters:
|
|
44
|
+
return Meters(
|
|
45
|
+
short_left,
|
|
46
|
+
short_reset or NOW + timedelta(hours=5),
|
|
47
|
+
weekly_left,
|
|
48
|
+
weekly_reset or NOW + timedelta(days=3),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _adapter(accounts: tuple[Account, ...], meters: dict[str, Meters | None]) -> ProductAdapter:
|
|
53
|
+
return ProductAdapter(
|
|
54
|
+
load_accounts=lambda: accounts,
|
|
55
|
+
read_meters=lambda account: meters[account.name],
|
|
56
|
+
environment_variable="CODEX_HOME",
|
|
57
|
+
usage_limit_signatures=("rate limit",),
|
|
58
|
+
main_guard=False,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_should_rank_by_room_in_the_tighter_window() -> None:
|
|
63
|
+
readings = (
|
|
64
|
+
Reading(_account("first"), _meters(12, 80)),
|
|
65
|
+
Reading(_account("second"), _meters(70, 40)),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
decision = choose_from_readings(Product.CODEX, readings, now=NOW)
|
|
69
|
+
|
|
70
|
+
assert decision.account == readings[1].account
|
|
71
|
+
assert decision.tier == "normal"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_should_put_unread_accounts_after_readable_accounts() -> None:
|
|
75
|
+
readings = (
|
|
76
|
+
Reading(_account("unread", Product.CLAUDE), None),
|
|
77
|
+
Reading(_account("readable", Product.CLAUDE), _meters(20, 20)),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
decision = choose_from_readings(Product.CLAUDE, readings, now=NOW)
|
|
81
|
+
|
|
82
|
+
assert decision.account == readings[1].account
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_should_keep_list_order_when_tighter_windows_tie() -> None:
|
|
86
|
+
readings = (
|
|
87
|
+
Reading(_account("first"), _meters(40, 60)),
|
|
88
|
+
Reading(_account("second"), _meters(70, 40)),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
decision = choose_from_readings(Product.CODEX, readings, now=NOW)
|
|
92
|
+
|
|
93
|
+
assert decision.account == readings[0].account
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.parametrize(
|
|
97
|
+
("weekly_reset", "weekly_left", "short_left"),
|
|
98
|
+
(
|
|
99
|
+
(NOW + timedelta(days=3), 20, 80),
|
|
100
|
+
(NOW + timedelta(hours=4), 10, 80),
|
|
101
|
+
(NOW + timedelta(hours=4), 20, 50),
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
def test_should_guard_main_at_each_limit(
|
|
105
|
+
weekly_reset: datetime, weekly_left: float, short_left: float
|
|
106
|
+
) -> None:
|
|
107
|
+
main = Reading(
|
|
108
|
+
_account("main", Product.CLAUDE, main=True),
|
|
109
|
+
_meters(short_left, weekly_left, weekly_reset=weekly_reset),
|
|
110
|
+
)
|
|
111
|
+
extra = Reading(_account("extra", Product.CLAUDE), _meters(20, 20))
|
|
112
|
+
|
|
113
|
+
decision = choose_from_readings(Product.CLAUDE, (main, extra), now=NOW)
|
|
114
|
+
|
|
115
|
+
assert decision.account == extra.account
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_should_prioritize_main_when_its_guard_passes() -> None:
|
|
119
|
+
main = Reading(
|
|
120
|
+
_account("main", Product.CLAUDE, main=True),
|
|
121
|
+
_meters(80, 20, weekly_reset=NOW + timedelta(hours=4)),
|
|
122
|
+
)
|
|
123
|
+
extra = Reading(_account("extra", Product.CLAUDE), _meters(90, 90))
|
|
124
|
+
|
|
125
|
+
decision = choose_from_readings(Product.CLAUDE, (main, extra), now=NOW)
|
|
126
|
+
|
|
127
|
+
assert decision.account == main.account
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_should_try_next_account_after_usage_limit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
131
|
+
accounts = (_account("first"), _account("second"))
|
|
132
|
+
adapter = _adapter(accounts, {"first": _meters(80, 80), "second": _meters(70, 70)})
|
|
133
|
+
attempted: list[str] = []
|
|
134
|
+
|
|
135
|
+
def runner(command: object, **options: object) -> subprocess.CompletedProcess[str]:
|
|
136
|
+
selected = Path(options["env"]["CODEX_HOME"]).name
|
|
137
|
+
attempted.append(selected)
|
|
138
|
+
if selected == "first":
|
|
139
|
+
return subprocess.CompletedProcess(command, 1, "", "rate limit")
|
|
140
|
+
return subprocess.CompletedProcess(command, 0, "served", "")
|
|
141
|
+
|
|
142
|
+
report_path = tmp_path / "report.json"
|
|
143
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, adapter)
|
|
144
|
+
with account_broker.override_subprocess_runner(runner):
|
|
145
|
+
outcome, report = account_broker._execute(
|
|
146
|
+
Product.CODEX,
|
|
147
|
+
("job",),
|
|
148
|
+
now=NOW,
|
|
149
|
+
)
|
|
150
|
+
account_broker._write_report(report_path, report)
|
|
151
|
+
|
|
152
|
+
report = json.loads(report_path.read_text(encoding="utf-8"))
|
|
153
|
+
assert outcome.returncode == 0
|
|
154
|
+
assert attempted == ["first", "second"]
|
|
155
|
+
assert [event["type"] for event in report["events"]] == ["pick", "attempt", "pick", "attempt"]
|
|
156
|
+
assert report["final_decision"]["account"] == "second"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_should_wait_for_soonest_reset_and_exit_three(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
160
|
+
accounts = (_account("first"), _account("second"))
|
|
161
|
+
first_reset = NOW + timedelta(hours=5)
|
|
162
|
+
second_reset = NOW + timedelta(hours=3)
|
|
163
|
+
adapter = _adapter(
|
|
164
|
+
accounts,
|
|
165
|
+
{
|
|
166
|
+
"first": _meters(0, 0, short_reset=first_reset, weekly_reset=first_reset),
|
|
167
|
+
"second": _meters(0, 0, short_reset=second_reset, weekly_reset=second_reset),
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
report_path = tmp_path / "report.json"
|
|
172
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, adapter)
|
|
173
|
+
with account_broker.override_subprocess_runner(lambda command, **options: pytest.fail("a waiting job ran")):
|
|
174
|
+
outcome, report = account_broker._execute(
|
|
175
|
+
Product.CODEX,
|
|
176
|
+
("job",),
|
|
177
|
+
now=NOW,
|
|
178
|
+
)
|
|
179
|
+
account_broker._write_report(report_path, report)
|
|
180
|
+
|
|
181
|
+
report = json.loads(report_path.read_text(encoding="utf-8"))
|
|
182
|
+
assert outcome.returncode == WAIT_EXIT_CODE
|
|
183
|
+
assert report["final_decision"]["action"] == "wait"
|
|
184
|
+
assert report["final_decision"]["resets_at"] == second_reset.isoformat()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_should_wait_until_both_blocking_windows_reset() -> None:
|
|
188
|
+
short_reset = NOW + timedelta(hours=2)
|
|
189
|
+
weekly_reset = NOW + timedelta(days=2)
|
|
190
|
+
reading = Reading(
|
|
191
|
+
_account("spent"),
|
|
192
|
+
_meters(0, 0, short_reset=short_reset, weekly_reset=weekly_reset),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
decision = choose_from_readings(Product.CODEX, (reading,), now=NOW)
|
|
196
|
+
|
|
197
|
+
assert decision.resets_at == weekly_reset
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_should_use_claude_extra_floors_and_wait_for_unread() -> None:
|
|
201
|
+
blocked = Reading(
|
|
202
|
+
_account("blocked", Product.CLAUDE), _meters(10, 5)
|
|
203
|
+
)
|
|
204
|
+
unread = Reading(_account("unread", Product.CLAUDE), None)
|
|
205
|
+
|
|
206
|
+
decision = choose_from_readings(Product.CLAUDE, (blocked, unread), now=NOW)
|
|
207
|
+
|
|
208
|
+
assert decision.action == "wait"
|
|
209
|
+
assert decision.account is None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@pytest.mark.parametrize("count", (1, 5))
|
|
213
|
+
def test_should_consider_every_account_in_the_roster(count: int) -> None:
|
|
214
|
+
readings = tuple(
|
|
215
|
+
Reading(_account(f"account_{index}"), _meters(30 + index, 30 + index))
|
|
216
|
+
for index in range(count)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
decision = choose_from_readings(Product.CODEX, readings, now=NOW)
|
|
220
|
+
|
|
221
|
+
assert decision.account == readings[-1].account
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_should_use_luna_floor_without_choosing_a_model() -> None:
|
|
225
|
+
reading = Reading(_account("low"), _meters(30, 8))
|
|
226
|
+
|
|
227
|
+
decision = choose_from_readings(Product.CODEX, (reading,), now=NOW)
|
|
228
|
+
|
|
229
|
+
assert decision.account == reading.account
|
|
230
|
+
assert decision.tier == "luna"
|
|
231
|
+
assert not hasattr(decision, "model")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_should_keep_resume_affinity_when_account_has_room() -> None:
|
|
235
|
+
readings = (
|
|
236
|
+
Reading(_account("roomy", Product.CLAUDE), _meters(80, 80)),
|
|
237
|
+
Reading(_account("origin", Product.CLAUDE), _meters(20, 20)),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
decision = choose_from_readings(
|
|
241
|
+
Product.CLAUDE, readings, now=NOW, preferred_command="origin"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
assert decision.account == readings[1].account
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_should_exit_three_for_check_while_all_accounts_wait(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
248
|
+
account = _account("spent")
|
|
249
|
+
monkeypatch.setitem(
|
|
250
|
+
account_broker.all_product_adapters,
|
|
251
|
+
Product.CODEX,
|
|
252
|
+
_adapter((account,), {"spent": _meters(0, 0)}),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
assert account_broker.main(("check", "--product", "codex")) == WAIT_EXIT_CODE
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_should_print_decision_and_meters_for_choose(
|
|
259
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
260
|
+
) -> None:
|
|
261
|
+
account = _account("roomy")
|
|
262
|
+
monkeypatch.setitem(
|
|
263
|
+
account_broker.all_product_adapters,
|
|
264
|
+
Product.CODEX,
|
|
265
|
+
_adapter((account,), {"roomy": _meters(80, 70)}),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
assert account_broker.main(("choose", "--product", "codex")) == 0
|
|
269
|
+
payload = json.loads(capsys.readouterr().out)
|
|
270
|
+
assert payload["decision"]["account"] == "roomy"
|
|
271
|
+
assert payload["accounts"][0]["meters"]["session_percent_left"] == 80
|
|
272
|
+
assert payload["state_path"].endswith("state.json")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_should_count_failed_meter_read_as_spent() -> None:
|
|
276
|
+
readings = (
|
|
277
|
+
Reading(_account("unread"), None),
|
|
278
|
+
Reading(_account("roomy"), _meters(70, 70)),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
decision = choose_from_readings(Product.CODEX, readings, now=NOW)
|
|
282
|
+
|
|
283
|
+
assert decision.account == readings[1].account
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_should_serialize_readings_with_named_fields() -> None:
|
|
287
|
+
reading = Reading(_account("one"), _meters(80, 70))
|
|
288
|
+
|
|
289
|
+
payload = account_broker.readings_payload((reading,))
|
|
290
|
+
|
|
291
|
+
assert payload[0]["name"] == "one"
|
|
292
|
+
assert payload[0]["meters"]["weekly_percent_left"] == 70
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_should_wait_when_all_meter_reads_fail() -> None:
|
|
296
|
+
readings = (Reading(_account("first"), None), Reading(_account("second"), None))
|
|
297
|
+
|
|
298
|
+
decision = choose_from_readings(Product.CODEX, readings, now=NOW)
|
|
299
|
+
|
|
300
|
+
assert decision.action == "wait"
|
|
301
|
+
assert decision.resets_at == NOW + timedelta(hours=1)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_should_replay_stdin_bytes_and_only_print_served_stdout(
|
|
305
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
306
|
+
) -> None:
|
|
307
|
+
accounts = (_account("first"), _account("second"))
|
|
308
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter(accounts, {
|
|
309
|
+
"first": _meters(80, 80), "second": _meters(70, 70)
|
|
310
|
+
}))
|
|
311
|
+
original = b"first\r\nsecond\x00\xff"
|
|
312
|
+
received: list[bytes] = []
|
|
313
|
+
|
|
314
|
+
def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
|
|
315
|
+
received.append(options["input"])
|
|
316
|
+
if len(received) == 1:
|
|
317
|
+
return subprocess.CompletedProcess(argv, 1, "discard this", "rate limit")
|
|
318
|
+
return subprocess.CompletedProcess(argv, 0, "command output", "")
|
|
319
|
+
|
|
320
|
+
monkeypatch.setattr(account_broker.sys, "stdin", io.TextIOWrapper(io.BytesIO(original), encoding="utf-8"))
|
|
321
|
+
report_path = tmp_path / "report.json"
|
|
322
|
+
with account_broker.override_subprocess_runner(runner):
|
|
323
|
+
code = account_broker.main(("run", "--product", "codex", "--report", str(report_path), "--", "job"))
|
|
324
|
+
|
|
325
|
+
captured_streams = capsys.readouterr()
|
|
326
|
+
assert code == 0
|
|
327
|
+
assert received == [original, original]
|
|
328
|
+
assert captured_streams.out == "command output"
|
|
329
|
+
assert json.loads(report_path.read_text(encoding="utf-8"))["final_decision"]["account"] == "second"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_should_write_wait_report_without_stdout(
|
|
333
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
334
|
+
) -> None:
|
|
335
|
+
account = _account("spent")
|
|
336
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"spent": _meters(0, 0)}))
|
|
337
|
+
monkeypatch.setattr(account_broker.sys, "stdin", io.TextIOWrapper(io.BytesIO(b""), encoding="utf-8"))
|
|
338
|
+
report_path = tmp_path / "report.json"
|
|
339
|
+
|
|
340
|
+
code = account_broker.main(("run", "--product", "codex", "--report", str(report_path), "--", "job"))
|
|
341
|
+
|
|
342
|
+
assert code == WAIT_EXIT_CODE
|
|
343
|
+
assert capsys.readouterr().out == ""
|
|
344
|
+
final = json.loads(report_path.read_text(encoding="utf-8"))["final_decision"]
|
|
345
|
+
assert final["action"] == "wait"
|
|
346
|
+
assert final["resets_at"] is not None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_should_keep_spent_mark_until_reset(
|
|
350
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
351
|
+
) -> None:
|
|
352
|
+
accounts = (_account("first"), _account("second"))
|
|
353
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter(accounts, {
|
|
354
|
+
"first": _meters(80, 80), "second": _meters(70, 70)
|
|
355
|
+
}))
|
|
356
|
+
reset = int((datetime.now(timezone.utc) + timedelta(hours=2)).timestamp())
|
|
357
|
+
|
|
358
|
+
assert account_broker.main(("choose", "--product", "codex", "--spent", f"first:{reset}")) == 0
|
|
359
|
+
assert json.loads(capsys.readouterr().out)["decision"]["account"] == "second"
|
|
360
|
+
assert account_broker.main(("choose", "--product", "codex")) == 0
|
|
361
|
+
assert json.loads(capsys.readouterr().out)["decision"]["account"] == "second"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@pytest.mark.parametrize(
|
|
365
|
+
("start_error", "expected_status"),
|
|
366
|
+
((OSError("missing command"), "start_failed"), (subprocess.TimeoutExpired("job", 1), "timeout")),
|
|
367
|
+
)
|
|
368
|
+
def test_should_leave_later_choices_open_after_start_failure(
|
|
369
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], start_error: Exception, expected_status: str
|
|
370
|
+
) -> None:
|
|
371
|
+
accounts = (_account("first"), _account("second"))
|
|
372
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter(accounts, {
|
|
373
|
+
"first": _meters(80, 80), "second": _meters(70, 70)
|
|
374
|
+
}))
|
|
375
|
+
|
|
376
|
+
def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
|
|
377
|
+
raise start_error
|
|
378
|
+
|
|
379
|
+
with account_broker.override_subprocess_runner(runner):
|
|
380
|
+
outcome, _ = account_broker._execute(Product.CODEX, ("job",), now=datetime.now(timezone.utc))
|
|
381
|
+
|
|
382
|
+
assert outcome.attempts == (("first", expected_status), ("second", expected_status))
|
|
383
|
+
assert account_broker.main(("choose", "--product", "codex")) == 0
|
|
384
|
+
assert json.loads(capsys.readouterr().out)["decision"]["account"] == "first"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def test_should_keep_usage_limited_account_spent_for_later_choices(
|
|
388
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
389
|
+
) -> None:
|
|
390
|
+
accounts = (_account("first"), _account("second"))
|
|
391
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter(accounts, {
|
|
392
|
+
"first": _meters(80, 80), "second": _meters(70, 70)
|
|
393
|
+
}))
|
|
394
|
+
|
|
395
|
+
def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
|
|
396
|
+
if Path(options["env"]["CODEX_HOME"]).name == "first":
|
|
397
|
+
return subprocess.CompletedProcess(argv, 1, "", "rate limit")
|
|
398
|
+
return subprocess.CompletedProcess(argv, 0, "served", "")
|
|
399
|
+
|
|
400
|
+
with account_broker.override_subprocess_runner(runner):
|
|
401
|
+
account_broker._execute(Product.CODEX, ("job",), now=datetime.now(timezone.utc))
|
|
402
|
+
|
|
403
|
+
assert account_broker.main(("choose", "--product", "codex")) == 0
|
|
404
|
+
assert json.loads(capsys.readouterr().out)["decision"]["account"] == "second"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def test_should_list_accounts_without_reading_meters(
|
|
408
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
409
|
+
) -> None:
|
|
410
|
+
account = _account("listed")
|
|
411
|
+
adapter = ProductAdapter(lambda: (account,), lambda _: pytest.fail("meter read"), "CODEX_HOME", (), False)
|
|
412
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, adapter)
|
|
413
|
+
|
|
414
|
+
assert account_broker.main(("accounts", "--product", "codex")) == 0
|
|
415
|
+
assert json.loads(capsys.readouterr().out) == {
|
|
416
|
+
"accounts": [{"name": "listed", "home": str(account.home), "is_main": False}]
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_should_use_default_codex_home_without_roster(
|
|
421
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
422
|
+
) -> None:
|
|
423
|
+
monkeypatch.delenv("CODEX_ACCOUNT_PROFILES", raising=False)
|
|
424
|
+
monkeypatch.setattr(account_broker.support.codex_account_choice, "default_profiles_root", lambda: tmp_path / "profiles")
|
|
425
|
+
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "default"))
|
|
426
|
+
|
|
427
|
+
assert account_broker.load_codex_accounts() == ()
|
|
428
|
+
decision = choose_from_readings(Product.CODEX, (), now=NOW)
|
|
429
|
+
assert decision.account.name == "default"
|
|
430
|
+
assert decision.account.home == (tmp_path / "default").resolve()
|
|
431
|
+
assert "no roster" in decision.reason
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def test_should_reuse_meter_cache_for_60_seconds() -> None:
|
|
435
|
+
account = _account("cached")
|
|
436
|
+
calls: list[str] = []
|
|
437
|
+
|
|
438
|
+
def read_meter(selected: Account) -> Meters:
|
|
439
|
+
calls.append(selected.name)
|
|
440
|
+
return _meters(80, 80)
|
|
441
|
+
|
|
442
|
+
adapter = ProductAdapter(lambda: (account,), read_meter, "CODEX_HOME", (), False)
|
|
443
|
+
state = account_broker._load_state(account_broker.broker_state_path())
|
|
444
|
+
|
|
445
|
+
account_broker.read_accounts(Product.CODEX, adapter, all_state=state, now=NOW)
|
|
446
|
+
account_broker.read_accounts(Product.CODEX, adapter, all_state=state, now=NOW + timedelta(seconds=59))
|
|
447
|
+
account_broker.read_accounts(Product.CODEX, adapter, all_state=state, now=NOW + timedelta(seconds=60))
|
|
448
|
+
assert calls == ["cached", "cached"]
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def test_should_route_resume_to_bound_account(
|
|
452
|
+
monkeypatch: pytest.MonkeyPatch
|
|
453
|
+
) -> None:
|
|
454
|
+
accounts = (_account("first", Product.CLAUDE), _account("second", Product.CLAUDE))
|
|
455
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CLAUDE, _adapter(accounts, {
|
|
456
|
+
"first": _meters(80, 80), "second": _meters(70, 70)
|
|
457
|
+
}))
|
|
458
|
+
chosen: list[str] = []
|
|
459
|
+
|
|
460
|
+
def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
|
|
461
|
+
chosen.append(Path(options["env"]["CODEX_HOME"]).name)
|
|
462
|
+
return subprocess.CompletedProcess(argv, 0, '{"session_id":"session-1"}', "")
|
|
463
|
+
|
|
464
|
+
with account_broker.override_subprocess_runner(runner):
|
|
465
|
+
first = run_job(Product.CLAUDE, ("job",))
|
|
466
|
+
second = run_job(Product.CLAUDE, ("job", "--resume", "session-1"))
|
|
467
|
+
|
|
468
|
+
assert first.session_id == "session-1"
|
|
469
|
+
assert second.account_name == "first"
|
|
470
|
+
assert chosen == ["first", "first"]
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def test_should_raise_configuration_error_for_broken_list(
|
|
474
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
475
|
+
) -> None:
|
|
476
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
477
|
+
directory = tmp_path / ".claude"
|
|
478
|
+
directory.mkdir()
|
|
479
|
+
(directory / "claude-chain.json").write_text('{"chain": "broken"}', encoding="utf-8")
|
|
480
|
+
|
|
481
|
+
with pytest.raises(account_broker.BrokerConfigurationError):
|
|
482
|
+
account_broker.load_claude_accounts()
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def test_should_run_job_through_override(
|
|
486
|
+
monkeypatch: pytest.MonkeyPatch
|
|
487
|
+
) -> None:
|
|
488
|
+
account = _account("only")
|
|
489
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"only": _meters(80, 80)}))
|
|
490
|
+
calls: list[bytes] = []
|
|
491
|
+
|
|
492
|
+
def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
|
|
493
|
+
calls.append(options["input"])
|
|
494
|
+
return subprocess.CompletedProcess(argv, 0, "done", "")
|
|
495
|
+
|
|
496
|
+
with account_broker.override_subprocess_runner(runner):
|
|
497
|
+
outcome = run_job(Product.CODEX, ("job",), stdin_text=b"input")
|
|
498
|
+
|
|
499
|
+
assert outcome.status == "served"
|
|
500
|
+
assert outcome.attempts == (("only", "served"),)
|
|
501
|
+
assert calls == [b"input"]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
SCRIPTS = Path(__file__).resolve().parent
|
|
8
|
+
ALLOWED_LEGACY_MODULES = frozenset(
|
|
9
|
+
{
|
|
10
|
+
"claude_account_choice.py",
|
|
11
|
+
"claude_account_worker.py",
|
|
12
|
+
"claude_chain_runner.py",
|
|
13
|
+
"claude_chain_usage.py",
|
|
14
|
+
"codex_account_choice.py",
|
|
15
|
+
"codex_account_meters.py",
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
ACCOUNT_LIST_CALLS = frozenset(
|
|
19
|
+
{
|
|
20
|
+
"load_chain",
|
|
21
|
+
"extra_config_directories",
|
|
22
|
+
"read_extra_accounts",
|
|
23
|
+
"codex_account_names",
|
|
24
|
+
"saved_codex_account_names",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
ACCOUNT_LIST_MARKERS = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"claude-chain.json",
|
|
30
|
+
"extra-profiles.json",
|
|
31
|
+
"account-launchers.json",
|
|
32
|
+
"EXTRA_PROFILES_FILE_NAME",
|
|
33
|
+
"CODEX_ACCOUNT_LAUNCHERS_FILE_NAME",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
ACCOUNT_ENVIRONMENT_MARKERS = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"CLAUDE_CONFIG_DIR",
|
|
39
|
+
"CODEX_HOME",
|
|
40
|
+
"CLAUDE_CONFIG_DIR_ENV_VAR",
|
|
41
|
+
"CODEX_HOME_ENVIRONMENT_VARIABLE",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _called_name(node: ast.Call) -> str | None:
|
|
47
|
+
if isinstance(node.func, ast.Name):
|
|
48
|
+
return node.func.id
|
|
49
|
+
if isinstance(node.func, ast.Attribute):
|
|
50
|
+
return node.func.attr
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sets_account_home(node: ast.AST) -> bool:
|
|
55
|
+
if isinstance(node, ast.Assign):
|
|
56
|
+
targets = node.targets
|
|
57
|
+
elif isinstance(node, ast.AnnAssign):
|
|
58
|
+
targets = (node.target,)
|
|
59
|
+
else:
|
|
60
|
+
return False
|
|
61
|
+
for target in targets:
|
|
62
|
+
if isinstance(target, ast.Subscript) and any(
|
|
63
|
+
marker in ast.unparse(target.slice) for marker in ACCOUNT_ENVIRONMENT_MARKERS
|
|
64
|
+
):
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _node_reads_account_list(node: ast.AST) -> bool:
|
|
70
|
+
if isinstance(node, ast.Call):
|
|
71
|
+
return _called_name(node) in ACCOUNT_LIST_CALLS
|
|
72
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
73
|
+
body = ast.unparse(node)
|
|
74
|
+
return "read_text" in body and any(
|
|
75
|
+
marker in body for marker in ACCOUNT_LIST_MARKERS
|
|
76
|
+
)
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _reads_account_list(tree: ast.AST) -> bool:
|
|
81
|
+
return any(_node_reads_account_list(node) for node in ast.walk(tree))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _chooses_account_in_module(path: Path) -> bool:
|
|
85
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
86
|
+
if _reads_account_list(tree):
|
|
87
|
+
return True
|
|
88
|
+
for node in ast.walk(tree):
|
|
89
|
+
if _sets_account_home(node):
|
|
90
|
+
return True
|
|
91
|
+
if isinstance(node, ast.Dict) and any(
|
|
92
|
+
key is not None
|
|
93
|
+
and any(marker in ast.unparse(key) for marker in ACCOUNT_ENVIRONMENT_MARKERS)
|
|
94
|
+
for key in node.keys
|
|
95
|
+
):
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_should_reject_new_account_pickers_outside_broker() -> None:
|
|
101
|
+
all_pickers = {
|
|
102
|
+
path.name
|
|
103
|
+
for path in SCRIPTS.rglob("*.py")
|
|
104
|
+
if not path.name.startswith("test_")
|
|
105
|
+
and "tests" not in path.parts
|
|
106
|
+
and _chooses_account_in_module(path)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
assert all_pickers <= ALLOWED_LEGACY_MODULES | {"account_broker.py", "account_broker_support.py"}
|