claude-dev-env 8.83.2 → 8.84.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 +1 -1
- package/docs/codex-accounts.md +2 -2
- package/package.json +1 -1
- package/scripts/account_broker_support.py +10 -1
- package/scripts/codex_account_meters.py +13 -6
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +5 -2
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +3 -0
- package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +7 -2
- package/scripts/test_account_broker.py +26 -0
- package/scripts/test_account_broker_support.py +15 -1
- package/scripts/test_codex_account_meters.py +10 -0
package/docs/account-broker.md
CHANGED
|
@@ -6,7 +6,7 @@ Claude's main home is `~/.claude`. Its `claude-chain.json` file contains a `chai
|
|
|
6
6
|
|
|
7
7
|
The optional `claude-account-order.json` file in the main home sets the Claude pick priority. It is a JSON list of account names, first choice first, such as `["claude-second", "claude"]`. An entry matches an account's name, its command, or its `claude-<name>` launcher, and `claude` names the main account. A new job goes to the first listed account with room. When no listed account has room, the broker picks the unlisted account with the most room, and it waits when no account has room. A resumed session stays on its bound account while that account has room. A listed name absent from the roster is skipped. Without the file, every job goes to the account with the most room. Edit the file to change the order; the next pick reads it.
|
|
8
8
|
|
|
9
|
-
Codex uses its configured account roster. When no Codex roster is configured, the broker chooses the default Codex home.
|
|
9
|
+
Codex uses its configured account roster. When no Codex roster is configured, the broker chooses the default Codex home. A new Codex job goes to an account on a Pro plan before any other account in the same tier. The plan comes from the account's meter reply, and `ALL_PRO_PLAN_TYPES` lists the Pro plans: `pro`, `prolite`, and `promax`. Among accounts of the same kind, the one with the most room wins. A Pro account below the `normal` floor still loses to any account with room in the `normal` tier.
|
|
10
10
|
|
|
11
11
|
## Commands
|
|
12
12
|
|
package/docs/codex-accounts.md
CHANGED
|
@@ -91,8 +91,8 @@ A window that reports no length counts as weekly.
|
|
|
91
91
|
|
|
92
92
|
| Condition | Answer |
|
|
93
93
|
|---|---|
|
|
94
|
-
| One or more accounts with more than 10% left | `normal` on
|
|
95
|
-
| No account over 10%, one or more over 1% | `luna` on
|
|
94
|
+
| One or more accounts with more than 10% left | `normal` on a Pro plan account (`pro`, `prolite` and `promax`) before any other account in this tier; among accounts of the same kind, the one with the most room |
|
|
95
|
+
| No account over 10%, one or more over 1% | `luna` on a Pro plan account (`pro`, `prolite` and `promax`) before any other account in this tier; among accounts of the same kind, the one with the most room |
|
|
96
96
|
| An account that reports a window shorter than a week, such as the 5-hour window, with under 20% of that window left | never takes `luna` |
|
|
97
97
|
| No account over 1% | `wait`, naming the next reset when known |
|
|
98
98
|
| A meter read fails | The broker reads it again, up to 3 reads, waiting 1 second and then 2 seconds |
|
package/package.json
CHANGED
|
@@ -315,6 +315,7 @@ def read_codex_account_meters(account: Account) -> Meters:
|
|
|
315
315
|
short_window.resets_at if short_window else None,
|
|
316
316
|
FULL_PERCENT - weekly_window.used_percent if weekly_window else None,
|
|
317
317
|
weekly_window.resets_at if weekly_window else None,
|
|
318
|
+
usage.plan_type,
|
|
318
319
|
)
|
|
319
320
|
|
|
320
321
|
|
|
@@ -500,6 +501,7 @@ def _meter_payload(meters: Meters | None) -> dict[str, object] | None:
|
|
|
500
501
|
"session_resets_at": utc_time_text(meters.session_resets_at),
|
|
501
502
|
"weekly_percent_left": meters.weekly_percent_left,
|
|
502
503
|
"weekly_resets_at": utc_time_text(meters.weekly_resets_at),
|
|
504
|
+
"plan_type": meters.plan_type,
|
|
503
505
|
}
|
|
504
506
|
|
|
505
507
|
|
|
@@ -509,7 +511,14 @@ def _meters_from_payload(raw_meters: object) -> Meters | None:
|
|
|
509
511
|
all_percentages = (raw_meters.get("session_percent_left"), raw_meters.get("weekly_percent_left"))
|
|
510
512
|
if any(each_percent is not None and not isinstance(each_percent, (int, float)) for each_percent in all_percentages):
|
|
511
513
|
return None
|
|
512
|
-
|
|
514
|
+
plan_type = raw_meters.get("plan_type")
|
|
515
|
+
return Meters(
|
|
516
|
+
all_percentages[0],
|
|
517
|
+
parse_utc_time(raw_meters.get("session_resets_at")),
|
|
518
|
+
all_percentages[1],
|
|
519
|
+
parse_utc_time(raw_meters.get("weekly_resets_at")),
|
|
520
|
+
plan_type if isinstance(plan_type, str) else None,
|
|
521
|
+
)
|
|
513
522
|
|
|
514
523
|
|
|
515
524
|
def _read_one_account(account: Account, adapter: ProductAdapter, all_cache: dict[str, object], now: datetime) -> tuple[Reading, bool]:
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
Codex reports usage through ``codex app-server`` and the JSON-RPC method
|
|
5
5
|
``account/rateLimits/read``. The reply names up to two windows, a short one
|
|
6
6
|
(5 hours) and a weekly one. The account broker counts the account's room as
|
|
7
|
-
the smaller of the two
|
|
7
|
+
the smaller of the two. The reply also names the account's plan, such as
|
|
8
|
+
``pro`` or ``team``::
|
|
8
9
|
|
|
9
10
|
5-hour window 30% used, week 85% used -> 15% left
|
|
10
11
|
week window only, 40% used -> 60% left
|
|
12
|
+
planType "promax" -> plan_type "promax"
|
|
13
|
+
no planType -> plan_type None
|
|
11
14
|
no window, error reply, no reply -> CodexMeterUnreadError
|
|
12
15
|
|
|
13
16
|
Every read failure raises ``CodexMeterUnreadError``. A missing Codex
|
|
@@ -92,9 +95,10 @@ class UsageWindow:
|
|
|
92
95
|
|
|
93
96
|
@dataclass(frozen=True)
|
|
94
97
|
class CodexAccountMeters:
|
|
95
|
-
"""One account's windows, with its tightest short window and its tightest weekly window."""
|
|
98
|
+
"""One account's windows and plan, with its tightest short window and its tightest weekly window."""
|
|
96
99
|
|
|
97
100
|
all_windows: tuple[UsageWindow, ...]
|
|
101
|
+
plan_type: str | None = None
|
|
98
102
|
|
|
99
103
|
@property
|
|
100
104
|
def short_window(self) -> UsageWindow | None:
|
|
@@ -176,14 +180,16 @@ def parse_rate_limits_reply(reply_text: str) -> CodexAccountMeters:
|
|
|
176
180
|
::
|
|
177
181
|
|
|
178
182
|
{"id": 2, "result": {"rateLimits": {"primary": {"usedPercent": 30, ...},
|
|
179
|
-
"secondary": {"usedPercent": 85, ...}
|
|
180
|
-
|
|
183
|
+
"secondary": {"usedPercent": 85, ...},
|
|
184
|
+
"planType": "promax"}}}
|
|
185
|
+
-> CodexAccountMeters with two windows, 15% left, plan_type "promax"
|
|
186
|
+
the same reply without "planType" -> plan_type None
|
|
181
187
|
|
|
182
188
|
Args:
|
|
183
189
|
reply_text: One line the ``codex app-server`` process wrote.
|
|
184
190
|
|
|
185
191
|
Returns:
|
|
186
|
-
The windows the reply carries.
|
|
192
|
+
The windows and the plan type the reply carries.
|
|
187
193
|
|
|
188
194
|
Raises:
|
|
189
195
|
CodexMeterUnreadError: The line is no reply, an error reply, or names
|
|
@@ -197,7 +203,8 @@ def parse_rate_limits_reply(reply_text: str) -> CodexAccountMeters:
|
|
|
197
203
|
)
|
|
198
204
|
if not all_windows:
|
|
199
205
|
raise CodexMeterUnreadError("rate-limit reply names no usage window")
|
|
200
|
-
|
|
206
|
+
plan_type = rate_limits.get("planType")
|
|
207
|
+
return CodexAccountMeters(all_windows=all_windows, plan_type=plan_type if isinstance(plan_type, str) else None)
|
|
201
208
|
|
|
202
209
|
|
|
203
210
|
def request_messages() -> list[dict[str, object]]:
|
|
@@ -20,6 +20,7 @@ from dev_env_scripts_constants.claude_account_constants import (
|
|
|
20
20
|
SECOND_WEEKLY_USED_CEILING_PERCENT,
|
|
21
21
|
)
|
|
22
22
|
from dev_env_scripts_constants.codex_account_constants import (
|
|
23
|
+
ALL_PRO_PLAN_TYPES,
|
|
23
24
|
CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
24
25
|
LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
|
|
25
26
|
LUNA_TIER_STOP_PERCENT_LEFT,
|
|
@@ -193,6 +194,7 @@ class Meters:
|
|
|
193
194
|
session_resets_at: datetime | None
|
|
194
195
|
weekly_percent_left: float | None
|
|
195
196
|
weekly_resets_at: datetime | None
|
|
197
|
+
plan_type: str | None = None
|
|
196
198
|
|
|
197
199
|
@property
|
|
198
200
|
def tightest_percent_left(self) -> float | None:
|
|
@@ -313,10 +315,11 @@ def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
|
|
|
313
315
|
return max(all_known_resets, default=None)
|
|
314
316
|
|
|
315
317
|
|
|
316
|
-
def _rank_key(reading: Reading) -> tuple[bool, int, float]:
|
|
318
|
+
def _rank_key(reading: Reading) -> tuple[bool, int, bool, float]:
|
|
317
319
|
room = reading.meters.tightest_percent_left if reading.meters else None
|
|
318
320
|
priority = reading.account.priority
|
|
319
|
-
|
|
321
|
+
is_pro_plan = reading.meters is not None and reading.meters.plan_type in ALL_PRO_PLAN_TYPES
|
|
322
|
+
return (priority is None, priority or 0, not is_pro_plan, -(room if room is not None else -1.0))
|
|
320
323
|
|
|
321
324
|
|
|
322
325
|
def _main_has_room(meters: Meters | None) -> bool:
|
|
@@ -99,6 +99,9 @@ LUNA_TIER_STOP_PERCENT_LEFT: float = 1.0
|
|
|
99
99
|
LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT: float = 20.0
|
|
100
100
|
"""An account with a window shorter than a week runs Luna only with this much of it left."""
|
|
101
101
|
|
|
102
|
+
ALL_PRO_PLAN_TYPES: frozenset[str] = frozenset({"pro", "prolite", "promax"})
|
|
103
|
+
"""Plan types of a personal Codex Pro subscription. The broker tries these accounts first."""
|
|
104
|
+
|
|
102
105
|
WEEKLY_WINDOW_MINUTES: int = 10080
|
|
103
106
|
"""Length of the weekly window. Any shorter window is a short window, such as 5 hours."""
|
|
104
107
|
|
|
@@ -86,9 +86,14 @@ def test_tier_bars_match_the_documented_table() -> None:
|
|
|
86
86
|
normal_bar = documented_percent(constants.NORMAL_TIER_MINIMUM_PERCENT_LEFT)
|
|
87
87
|
luna_stop = documented_percent(constants.LUNA_TIER_STOP_PERCENT_LEFT)
|
|
88
88
|
luna_short_window_floor = documented_percent(constants.LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT)
|
|
89
|
+
pro_plans = documented_list(sorted(constants.ALL_PRO_PLAN_TYPES))
|
|
90
|
+
pro_preference = (
|
|
91
|
+
f"on a Pro plan account ({pro_plans}) before any other account in this tier; "
|
|
92
|
+
"among accounts of the same kind, the one with the most room"
|
|
93
|
+
)
|
|
89
94
|
all_expected_rows = [
|
|
90
|
-
f"| One or more accounts with more than {normal_bar} left | `{constants.TIER_NORMAL}`
|
|
91
|
-
f"| No account over {normal_bar}, one or more over {luna_stop} | `{constants.TIER_LUNA}`
|
|
95
|
+
f"| One or more accounts with more than {normal_bar} left | `{constants.TIER_NORMAL}` {pro_preference} |",
|
|
96
|
+
f"| No account over {normal_bar}, one or more over {luna_stop} | `{constants.TIER_LUNA}` {pro_preference} |",
|
|
92
97
|
f"| An account that reports a window shorter than a week, such as the 5-hour window, with under {luna_short_window_floor} of that window left | never takes `{constants.TIER_LUNA}` |",
|
|
93
98
|
f"| No account over {luna_stop} | `{constants.TIER_WAIT}`, naming the next reset when known |",
|
|
94
99
|
]
|
|
@@ -58,12 +58,14 @@ def _meters(
|
|
|
58
58
|
*,
|
|
59
59
|
short_reset: datetime | None = None,
|
|
60
60
|
weekly_reset: datetime | None = None,
|
|
61
|
+
plan_type: str | None = None,
|
|
61
62
|
) -> Meters:
|
|
62
63
|
return Meters(
|
|
63
64
|
short_left,
|
|
64
65
|
short_reset or NOW + timedelta(hours=5),
|
|
65
66
|
weekly_left,
|
|
66
67
|
weekly_reset or NOW + timedelta(days=3),
|
|
68
|
+
plan_type,
|
|
67
69
|
)
|
|
68
70
|
|
|
69
71
|
|
|
@@ -125,6 +127,30 @@ def test_should_put_unread_accounts_after_readable_accounts() -> None:
|
|
|
125
127
|
assert decision.account == readings[1].account
|
|
126
128
|
|
|
127
129
|
|
|
130
|
+
def test_should_pick_a_pro_plan_account_over_a_roomier_team_account() -> None:
|
|
131
|
+
readings = (
|
|
132
|
+
Reading(_account("team-mel"), _meters(90, 85, plan_type="team")),
|
|
133
|
+
Reading(_account("personal"), _meters(40, 30, plan_type="promax")),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
decision = choose_from_readings(Product.CODEX, readings, now=NOW)
|
|
137
|
+
|
|
138
|
+
assert decision.account == readings[1].account
|
|
139
|
+
assert decision.tier == "normal"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_should_pick_a_team_account_in_the_normal_tier_over_a_pro_plan_account_below_the_floor() -> None:
|
|
143
|
+
readings = (
|
|
144
|
+
Reading(_account("personal"), _meters(50, 8, plan_type="pro")),
|
|
145
|
+
Reading(_account("team-jon"), _meters(40, 30, plan_type="team")),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
decision = choose_from_readings(Product.CODEX, readings, now=NOW)
|
|
149
|
+
|
|
150
|
+
assert decision.account == readings[1].account
|
|
151
|
+
assert decision.tier == "normal"
|
|
152
|
+
|
|
153
|
+
|
|
128
154
|
def test_should_keep_list_order_when_tighter_windows_tie() -> None:
|
|
129
155
|
readings = (
|
|
130
156
|
Reading(_account("first"), _meters(40, 60)),
|
|
@@ -192,7 +192,8 @@ def test_should_read_codex_meters_through_injected_reader(
|
|
|
192
192
|
UsageWindow(300, 20.0, short_reset),
|
|
193
193
|
UsageWindow(10080, 30.0, None),
|
|
194
194
|
UsageWindow(None, 50.0, unknown_length_reset),
|
|
195
|
-
)
|
|
195
|
+
),
|
|
196
|
+
plan_type="promax",
|
|
196
197
|
)
|
|
197
198
|
monkeypatch.setattr(support.codex_account_meters, "resolve_codex_path", lambda _: tmp_path / "codex")
|
|
198
199
|
monkeypatch.setattr(support.codex_account_meters, "read_codex_meters", lambda *_: usage)
|
|
@@ -203,6 +204,19 @@ def test_should_read_codex_meters_through_injected_reader(
|
|
|
203
204
|
assert meters.session_resets_at == short_reset
|
|
204
205
|
assert meters.weekly_percent_left == 50.0
|
|
205
206
|
assert meters.weekly_resets_at == unknown_length_reset
|
|
207
|
+
assert meters.plan_type == "promax"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_should_keep_the_plan_through_the_cached_meter_payload() -> None:
|
|
211
|
+
meters = Meters(80.0, None, 70.0, None, "pro")
|
|
212
|
+
|
|
213
|
+
assert support._meters_from_payload(support._meter_payload(meters)) == meters
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_should_load_a_cached_meter_payload_saved_without_a_plan() -> None:
|
|
217
|
+
old_payload = {"session_percent_left": 80.0, "session_resets_at": None, "weekly_percent_left": 70.0, "weekly_resets_at": None}
|
|
218
|
+
|
|
219
|
+
assert support._meters_from_payload(old_payload) == Meters(80.0, None, 70.0, None, None)
|
|
206
220
|
|
|
207
221
|
|
|
208
222
|
def _codex_meter_reads(
|
|
@@ -65,6 +65,16 @@ class TestParseRateLimitsReply:
|
|
|
65
65
|
)
|
|
66
66
|
assert [each.used_percent for each in meters.all_windows] == [40.0]
|
|
67
67
|
|
|
68
|
+
def should_read_the_plan_type_beside_the_windows(self) -> None:
|
|
69
|
+
meters = parse_rate_limits_reply(
|
|
70
|
+
reply_line({"primary": {"usedPercent": 40}, "planType": "promax"})
|
|
71
|
+
)
|
|
72
|
+
assert meters.plan_type == "promax"
|
|
73
|
+
|
|
74
|
+
def should_read_a_reply_without_a_plan_type(self) -> None:
|
|
75
|
+
meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 40}}))
|
|
76
|
+
assert meters.plan_type is None
|
|
77
|
+
|
|
68
78
|
def should_clamp_used_percent_past_full(self) -> None:
|
|
69
79
|
meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 130}}))
|
|
70
80
|
assert [each.used_percent for each in meters.all_windows] == [100.0]
|