claude-dev-env 8.37.2 → 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.
@@ -0,0 +1,377 @@
1
+ """Choose and run Claude or Codex jobs through one account roster."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import tempfile
9
+ import threading
10
+ from contextlib import contextmanager
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Iterator, Sequence
14
+
15
+ import codex_account_choice
16
+ import codex_account_meters
17
+ from claude_account_profile import default_profile_home, validate_profile_name
18
+ from claude_chain_usage import WeeklyUtilizationProbeError, probe_account_meters
19
+ from dev_env_scripts_constants.account_broker_constants import (
20
+ BROKER_STATE_DIRECTORY_NAME,
21
+ BROKER_STATE_FILE_NAME,
22
+ BROKER_STATE_TEMP_SUFFIX,
23
+ Account,
24
+ BrokerConfigurationError,
25
+ Decision,
26
+ JobOutcome,
27
+ Meters,
28
+ Product,
29
+ ProductAdapter,
30
+ Reading,
31
+ Report,
32
+ REPORT_INDENT_SPACES,
33
+ SubprocessRunner,
34
+ codex_usage_limit_signatures,
35
+ parse_utc_time,
36
+ utc_time_text,
37
+ )
38
+ from dev_env_scripts_constants.claude_account_constants import (
39
+ CREDENTIALS_FILE_NAME,
40
+ EXTRA_PROFILES_FILE_NAME,
41
+ FULL_PERCENT,
42
+ MAIN_CLAUDE_HOME_DIRECTORY_NAME,
43
+ )
44
+ from dev_env_scripts_constants.claude_chain_constants import ALL_USAGE_LIMIT_SIGNATURES
45
+ from dev_env_scripts_constants.codex_account_constants import (
46
+ CODEX_ACCOUNT_LAUNCHERS_FILE_NAME,
47
+ CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
48
+ CODEX_HOME_ENVIRONMENT_VARIABLE,
49
+ WEEKLY_WINDOW_MINUTES,
50
+ )
51
+ from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
52
+
53
+
54
+ def _read_list(path: Path) -> object:
55
+ try:
56
+ return json.loads(path.read_text(encoding="utf-8"))
57
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
58
+ raise BrokerConfigurationError(f"cannot read account list {path}: {error}") from error
59
+
60
+
61
+ def _chain_entry(raw_entry: object, main_home: Path, chain_path: Path) -> Account:
62
+ if not isinstance(raw_entry, dict):
63
+ raise BrokerConfigurationError(f"invalid account list {chain_path}")
64
+ command = raw_entry.get("command")
65
+ credential = raw_entry.get("credentials_path")
66
+ extra_args = raw_entry.get("extra_args", [])
67
+ if not isinstance(command, str) or not command:
68
+ raise BrokerConfigurationError(f"invalid account list {chain_path}")
69
+ if credential is not None and (not isinstance(credential, str) or not credential):
70
+ raise BrokerConfigurationError(f"invalid account list {chain_path}")
71
+ if not isinstance(extra_args, list) or any(not isinstance(each_arg, str) for each_arg in extra_args):
72
+ raise BrokerConfigurationError(f"invalid account list {chain_path}")
73
+ home = Path(credential).expanduser().parent.resolve() if credential else main_home
74
+ return Account(Product.CLAUDE, command, home, command=command)
75
+
76
+
77
+ def _chain_entries(chain_path: Path, main_home: Path) -> tuple[Account, ...]:
78
+ if not chain_path.exists():
79
+ return ()
80
+ document = _read_list(chain_path)
81
+ all_entries = document.get("chain") if isinstance(document, dict) else None
82
+ if not isinstance(all_entries, list) or not all_entries:
83
+ raise BrokerConfigurationError(f"invalid account list {chain_path}")
84
+ return tuple(_chain_entry(each_entry, main_home, chain_path) for each_entry in all_entries)
85
+
86
+
87
+ def _extra_homes(main_home: Path) -> tuple[Path, ...]:
88
+ extras_path = main_home / EXTRA_PROFILES_FILE_NAME
89
+ if not extras_path.exists():
90
+ return (default_profile_home(),)
91
+ all_names = _read_list(extras_path)
92
+ if not isinstance(all_names, list) or not all_names or any(not isinstance(each_name, str) for each_name in all_names):
93
+ raise BrokerConfigurationError(f"invalid account list {extras_path}")
94
+ if len({each_name.casefold() for each_name in all_names}) != len(all_names):
95
+ raise BrokerConfigurationError(f"duplicate account in {extras_path}")
96
+ try:
97
+ return tuple(default_profile_home(validate_profile_name(each_name)) for each_name in all_names)
98
+ except ValueError as error:
99
+ raise BrokerConfigurationError(f"invalid account list {extras_path}: {error}") from error
100
+
101
+
102
+ def _append_claude_account(
103
+ all_accounts: list[Account], all_seen: set[str], account: Account, main_home: Path
104
+ ) -> None:
105
+ key = str(account.home).casefold()
106
+ if key == str(main_home).casefold():
107
+ all_accounts[0] = Account(Product.CLAUDE, "main", main_home, True, account.command)
108
+ elif key not in all_seen:
109
+ all_accounts.append(account)
110
+ all_seen.add(key)
111
+
112
+
113
+ def load_claude_accounts() -> tuple[Account, ...]:
114
+ """Read the Claude account roster.
115
+
116
+ Returns:
117
+ Main and additional accounts in configured order.
118
+
119
+ Raises:
120
+ BrokerConfigurationError: An account list is unreadable.
121
+ """
122
+ main_home = (Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME).resolve()
123
+ all_accounts = [Account(Product.CLAUDE, "main", main_home, True, "claude")]
124
+ all_seen = {str(main_home).casefold()}
125
+ for each_entry in _chain_entries(main_home / "claude-chain.json", main_home):
126
+ _append_claude_account(all_accounts, all_seen, each_entry, main_home)
127
+ for each_home in _extra_homes(main_home):
128
+ home = each_home.resolve()
129
+ _append_claude_account(all_accounts, all_seen, Account(Product.CLAUDE, home.name, home, command=home.name), main_home)
130
+ return tuple(all_accounts)
131
+
132
+
133
+ def load_codex_accounts() -> tuple[Account, ...]:
134
+ """Read the Codex account roster.
135
+
136
+ Returns:
137
+ Configured accounts, or an empty tuple when none are configured.
138
+
139
+ Raises:
140
+ BrokerConfigurationError: The configured roster is unreadable.
141
+ """
142
+ profiles_root = codex_account_choice.default_profiles_root().resolve()
143
+ roster_path = profiles_root / CODEX_ACCOUNT_LAUNCHERS_FILE_NAME
144
+ configured = bool(os.environ.get(CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE, "").strip())
145
+ if not configured and not roster_path.exists():
146
+ return ()
147
+ if not configured and _read_list(roster_path) == []:
148
+ return ()
149
+ try:
150
+ names = codex_account_choice.codex_account_names(profiles_root)
151
+ except (OSError, ValueError) as error:
152
+ raise BrokerConfigurationError(f"cannot read account list {roster_path}: {error}") from error
153
+ return tuple(Account(Product.CODEX, name, (profiles_root / name).resolve()) for name in names)
154
+
155
+
156
+ def read_claude_meters(account: Account) -> Meters | None:
157
+ """Probe one Claude account.
158
+
159
+ Args:
160
+ account: Account whose meter is read.
161
+
162
+ Returns:
163
+ Its meters, or None when the probe fails.
164
+ """
165
+ try:
166
+ usage = probe_account_meters(account.home / CREDENTIALS_FILE_NAME)
167
+ except (WeeklyUtilizationProbeError, OSError):
168
+ return None
169
+ return Meters(
170
+ FULL_PERCENT - usage.session_utilization if usage.session_utilization is not None else None,
171
+ usage.session_resets_at,
172
+ FULL_PERCENT - usage.weekly_utilization if usage.weekly_utilization is not None else None,
173
+ usage.weekly_resets_at,
174
+ )
175
+
176
+
177
+ def read_codex_account_meters(account: Account) -> Meters | None:
178
+ """Probe one Codex account.
179
+
180
+ Args:
181
+ account: Account whose meter is read.
182
+
183
+ Returns:
184
+ Its meters, or None when the probe fails.
185
+ """
186
+ try:
187
+ codex_path = codex_account_meters.resolve_codex_path(None)
188
+ usage = codex_account_meters.read_codex_meters(codex_path, account.home)
189
+ except (codex_account_meters.CodexMeterUnreadError, OSError):
190
+ return None
191
+ weekly = [window for window in usage.all_windows if window.duration_minutes is None or window.duration_minutes >= WEEKLY_WINDOW_MINUTES]
192
+ short = [window for window in usage.all_windows if window.duration_minutes is not None and window.duration_minutes < WEEKLY_WINDOW_MINUTES]
193
+ weekly_window = max(weekly, key=lambda window: window.used_percent) if weekly else None
194
+ short_window = max(short, key=lambda window: window.used_percent) if short else None
195
+ return Meters(
196
+ usage.short_window_percent_left,
197
+ short_window.resets_at if short_window else None,
198
+ FULL_PERCENT - weekly_window.used_percent if weekly_window else None,
199
+ weekly_window.resets_at if weekly_window else None,
200
+ )
201
+
202
+
203
+ all_product_adapters = {
204
+ Product.CLAUDE: ProductAdapter(load_claude_accounts, read_claude_meters, CLAUDE_CONFIG_DIR_ENV_VAR, ALL_USAGE_LIMIT_SIGNATURES, True),
205
+ Product.CODEX: ProductAdapter(load_codex_accounts, read_codex_account_meters, CODEX_HOME_ENVIRONMENT_VARIABLE, codex_usage_limit_signatures(), False),
206
+ }
207
+
208
+
209
+ def broker_state_path() -> Path:
210
+ return Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME / BROKER_STATE_DIRECTORY_NAME / BROKER_STATE_FILE_NAME
211
+
212
+
213
+ def _load_state(path: Path) -> dict[str, object]:
214
+ if not path.exists():
215
+ return {"meters": {}, "spent": {}, "affinity": {}}
216
+ try:
217
+ parsed_state = json.loads(path.read_text(encoding="utf-8"))
218
+ except (OSError, UnicodeError, json.JSONDecodeError):
219
+ return {"meters": {}, "spent": {}, "affinity": {}}
220
+ if not isinstance(parsed_state, dict):
221
+ return {"meters": {}, "spent": {}, "affinity": {}}
222
+ return {key: parsed_state.get(key) if isinstance(parsed_state.get(key), dict) else {} for key in ("meters", "spent", "affinity")}
223
+
224
+
225
+ def _save_state(path: Path, all_state: dict[str, object]) -> None:
226
+ path.parent.mkdir(parents=True, exist_ok=True)
227
+ temporary_name: str | None = None
228
+ try:
229
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, prefix="state-", suffix=BROKER_STATE_TEMP_SUFFIX, delete=False, newline="\n") as stream:
230
+ temporary_name = stream.name
231
+ json.dump(all_state, stream, indent=REPORT_INDENT_SPACES, sort_keys=True)
232
+ stream.write("\n")
233
+ os.replace(temporary_name, path)
234
+ finally:
235
+ if temporary_name is not None and os.path.exists(temporary_name):
236
+ os.unlink(temporary_name)
237
+
238
+
239
+ def _state_key(account: Account) -> str:
240
+ return f"{account.product.value}:{account.name}:{account.home}"
241
+
242
+
243
+ def _meter_payload(meters: Meters | None) -> dict[str, object] | None:
244
+ if meters is None:
245
+ return None
246
+ return {
247
+ "session_percent_left": meters.session_percent_left,
248
+ "session_resets_at": utc_time_text(meters.session_resets_at),
249
+ "weekly_percent_left": meters.weekly_percent_left,
250
+ "weekly_resets_at": utc_time_text(meters.weekly_resets_at),
251
+ }
252
+
253
+
254
+ def _meters_from_payload(raw_meters: object) -> Meters | None:
255
+ if not isinstance(raw_meters, dict):
256
+ return None
257
+ all_percentages = (raw_meters.get("session_percent_left"), raw_meters.get("weekly_percent_left"))
258
+ if any(each_percent is not None and not isinstance(each_percent, (int, float)) for each_percent in all_percentages):
259
+ return None
260
+ return Meters(all_percentages[0], parse_utc_time(raw_meters.get("session_resets_at")), all_percentages[1], parse_utc_time(raw_meters.get("weekly_resets_at")))
261
+
262
+
263
+ def _read_one_account(account: Account, adapter: ProductAdapter, all_cache: dict[str, object], now: datetime) -> tuple[Reading, bool]:
264
+ key = _state_key(account)
265
+ cached = all_cache.get(key)
266
+ if isinstance(cached, dict) and isinstance(cached.get("read_at"), (int, float)) and 0 <= now.timestamp() - cached["read_at"] < 60:
267
+ return Reading(account, _meters_from_payload(cached.get("meters"))), False
268
+ try:
269
+ meters = adapter.read_meters(account)
270
+ except (OSError, ValueError, RuntimeError, subprocess.SubprocessError):
271
+ meters = None
272
+ all_cache[key] = {"read_at": now.timestamp(), "meters": _meter_payload(meters)}
273
+ return Reading(account, meters), True
274
+
275
+
276
+ def read_accounts(product: Product, adapter: ProductAdapter | None = None, *, all_state: dict[str, object] | None = None, now: datetime | None = None) -> tuple[Reading, ...]:
277
+ """Read account meters with a 60-second cache.
278
+
279
+ Args:
280
+ product: Product whose accounts are read.
281
+ adapter: Injected account and meter readers.
282
+ all_state: Mutable broker state document.
283
+ now: Clock instant used for cache age.
284
+
285
+ Returns:
286
+ Account readings in roster order.
287
+ """
288
+ active = adapter or all_product_adapters[product]
289
+ current = now or datetime.now(timezone.utc)
290
+ all_cache = all_state.get("meters", {}) if all_state is not None else {}
291
+ all_readings: list[Reading] = []
292
+ is_updated = False
293
+ for each_account in active.load_accounts():
294
+ reading, did_read = _read_one_account(each_account, active, all_cache, current)
295
+ all_readings.append(reading)
296
+ is_updated = is_updated or did_read
297
+ if is_updated and all_state is not None:
298
+ _save_state(broker_state_path(), all_state)
299
+ return tuple(all_readings)
300
+
301
+
302
+ def _session_id_from_json(value: str) -> str | None:
303
+ try:
304
+ payload = json.loads(value)
305
+ except json.JSONDecodeError:
306
+ return None
307
+ session_id = payload.get("session_id") if isinstance(payload, dict) else None
308
+ return session_id if isinstance(session_id, str) and session_id else None
309
+
310
+
311
+ def extract_session_id_from_stdout(stdout: str) -> str | None:
312
+ """Find a session id in JSON or newline-delimited JSON.
313
+
314
+ Args:
315
+ stdout: Captured command output.
316
+
317
+ Returns:
318
+ Session id, if present.
319
+ """
320
+ return _session_id_from_json(stdout.strip()) or next(
321
+ (found for line in stdout.splitlines() if (found := _session_id_from_json(line.strip())) is not None),
322
+ None,
323
+ )
324
+
325
+
326
+ def _resume_id(all_argv: Sequence[str]) -> str | None:
327
+ for each_index, each_argument in enumerate(all_argv):
328
+ if each_argument == "--resume" and each_index + 1 < len(all_argv):
329
+ candidate = all_argv[each_index + 1]
330
+ return candidate if candidate and not candidate.startswith("-") else None
331
+ return None
332
+
333
+
334
+ def _run_captured_subprocess(all_argv: Sequence[str], **options: object) -> subprocess.CompletedProcess[str]:
335
+ encoding = str(options.get("encoding") or "utf-8")
336
+ errors = str(options.get("errors") or "replace")
337
+ stdin_bytes = options.get("input")
338
+ with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
339
+ completion = subprocess.run(
340
+ list(all_argv),
341
+ input=stdin_bytes,
342
+ stdout=stdout_file,
343
+ stderr=stderr_file,
344
+ env=options.get("env"),
345
+ cwd=options.get("cwd"),
346
+ timeout=options.get("timeout"),
347
+ check=False,
348
+ )
349
+ stdout_file.seek(0)
350
+ stderr_file.seek(0)
351
+ stdout = stdout_file.read().decode(encoding, errors).replace("\r\n", "\n").replace("\r", "\n")
352
+ stderr = stderr_file.read().decode(encoding, errors).replace("\r\n", "\n").replace("\r", "\n")
353
+ return subprocess.CompletedProcess(list(all_argv), completion.returncode, stdout, stderr)
354
+
355
+
356
+ subprocess_runner: SubprocessRunner = _run_captured_subprocess
357
+ _subprocess_runner_lock = threading.Lock()
358
+
359
+
360
+ @contextmanager
361
+ def override_subprocess_runner(runner: SubprocessRunner) -> Iterator[SubprocessRunner]:
362
+ """Replace the captured subprocess runner within a context.
363
+
364
+ Args:
365
+ runner: Replacement runner.
366
+
367
+ Yields:
368
+ Runner that was active before replacement.
369
+ """
370
+ global subprocess_runner
371
+ with _subprocess_runner_lock:
372
+ previous = subprocess_runner
373
+ subprocess_runner = runner
374
+ try:
375
+ yield previous
376
+ finally:
377
+ subprocess_runner = previous
@@ -0,0 +1,301 @@
1
+ """Account floors and command status values for the shared account broker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import os
7
+ import subprocess
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timedelta, timezone
10
+ from enum import Enum
11
+ from pathlib import Path
12
+ from typing import Callable, Mapping, Sequence
13
+
14
+ from dev_env_scripts_constants.claude_account_constants import (
15
+ FULL_PERCENT,
16
+ MAIN_SESSION_USED_CEILING_PERCENT,
17
+ MAIN_SPEND_WINDOW,
18
+ MAIN_WEEKLY_USED_CEILING_PERCENT,
19
+ SECOND_SESSION_USED_CEILING_PERCENT,
20
+ SECOND_WEEKLY_USED_CEILING_PERCENT,
21
+ )
22
+ from dev_env_scripts_constants.codex_account_constants import (
23
+ CODEX_HOME_ENVIRONMENT_VARIABLE,
24
+ LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
25
+ LUNA_TIER_STOP_PERCENT_LEFT,
26
+ MAIN_CODEX_HOME_DIRECTORY_NAME,
27
+ NORMAL_TIER_MINIMUM_PERCENT_LEFT,
28
+ TIER_LUNA,
29
+ TIER_NORMAL,
30
+ )
31
+
32
+ ALL_CLAUDE_FLOORS = {
33
+ "main_weekly_used_ceiling": MAIN_WEEKLY_USED_CEILING_PERCENT,
34
+ "main_session_used_ceiling": MAIN_SESSION_USED_CEILING_PERCENT,
35
+ "main_spend_window": MAIN_SPEND_WINDOW,
36
+ "extra_weekly_used_ceiling": SECOND_WEEKLY_USED_CEILING_PERCENT,
37
+ "extra_session_used_ceiling": SECOND_SESSION_USED_CEILING_PERCENT,
38
+ }
39
+
40
+ ALL_CODEX_FLOORS = {
41
+ "normal_minimum_left": NORMAL_TIER_MINIMUM_PERCENT_LEFT,
42
+ "luna_stop_left": LUNA_TIER_STOP_PERCENT_LEFT,
43
+ "luna_short_minimum_left": LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
44
+ }
45
+
46
+ WAIT_EXIT_CODE = 3
47
+ COMMAND_MISSING_EXIT_CODE = 127
48
+ REPORT_INDENT_SPACES = 2
49
+ BROKER_STATE_DIRECTORY_NAME = "account-broker"
50
+ BROKER_STATE_FILE_NAME = "state.json"
51
+ BROKER_STATE_TEMP_SUFFIX = ".json"
52
+ SECONDS_PER_HOUR = 3600
53
+
54
+
55
+ def codex_usage_limit_signatures() -> tuple[str, ...]:
56
+ """Load usage limit markers.
57
+
58
+ Returns:
59
+ Markers that identify usage limit responses.
60
+
61
+ Raises:
62
+ ImportError: The marker module cannot be loaded.
63
+ """
64
+ constants_path = Path(__file__).resolve().parents[2] / "_shared" / "pr-loop" / "scripts" / "codex_review_scripts_constants" / "classifier_constants.py"
65
+ specification = importlib.util.spec_from_file_location("account_broker_codex_classifier_constants", constants_path)
66
+ if specification is None or specification.loader is None:
67
+ raise ImportError(f"cannot load usage markers from {constants_path}")
68
+ module = importlib.util.module_from_spec(specification)
69
+ specification.loader.exec_module(module)
70
+ return module.ALL_USAGE_LIMIT_MARKERS
71
+
72
+
73
+ def utc_time_text(moment: datetime | None) -> str | None:
74
+ return moment.astimezone(timezone.utc).isoformat() if moment is not None else None
75
+
76
+
77
+ def parse_utc_time(timestamp: object) -> datetime | None:
78
+ """Parse an ISO timestamp as UTC.
79
+
80
+ Args:
81
+ timestamp: Serialized timestamp.
82
+
83
+ Returns:
84
+ UTC timestamp or None.
85
+ """
86
+ if not isinstance(timestamp, str):
87
+ return None
88
+ try:
89
+ parsed = datetime.fromisoformat(timestamp)
90
+ except ValueError:
91
+ return None
92
+ return parsed.astimezone(timezone.utc) if parsed.tzinfo is not None else None
93
+
94
+
95
+ class Product(str, Enum):
96
+ CLAUDE = "claude"
97
+ CODEX = "codex"
98
+
99
+
100
+ class BrokerConfigurationError(Exception):
101
+ """An account list cannot be read."""
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class Account:
106
+ product: Product
107
+ name: str
108
+ home: Path
109
+ is_main: bool = False
110
+ command: str | None = None
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class Meters:
115
+ session_percent_left: float | None
116
+ session_resets_at: datetime | None
117
+ weekly_percent_left: float | None
118
+ weekly_resets_at: datetime | None
119
+
120
+ @property
121
+ def tightest_percent_left(self) -> float | None:
122
+ all_percentages = (self.session_percent_left, self.weekly_percent_left)
123
+ return min((each_percent for each_percent in all_percentages if each_percent is not None), default=None)
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class Reading:
128
+ account: Account
129
+ meters: Meters | None
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class Decision:
134
+ action: str
135
+ account: Account | None
136
+ resets_at: datetime | None
137
+ reason: str
138
+ tier: str | None
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class JobOutcome:
143
+ returncode: int
144
+ stdout: str
145
+ stderr: str
146
+ account_name: str | None
147
+ attempts: tuple[tuple[str, str], ...]
148
+ status: str
149
+ session_id: str | None
150
+ wait_reset_at: datetime | None
151
+
152
+
153
+ @dataclass
154
+ class Report:
155
+ product: Product
156
+ command: list[str]
157
+ events: list[dict[str, object]] = field(default_factory=list)
158
+ final_decision: Decision | None = None
159
+
160
+
161
+ MeterReader = Callable[[Account], Meters | None]
162
+ AccountLoader = Callable[[], Sequence[Account]]
163
+ SubprocessRunner = Callable[..., subprocess.CompletedProcess[str]]
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class ProductAdapter:
168
+ load_accounts: AccountLoader
169
+ read_meters: MeterReader
170
+ environment_variable: str
171
+ usage_limit_signatures: tuple[str, ...]
172
+ main_guard: bool
173
+
174
+
175
+
176
+ def _main_reason(meters: Meters | None, now: datetime) -> str | None:
177
+ if meters is None or meters.session_percent_left is None or meters.weekly_percent_left is None or meters.weekly_resets_at is None:
178
+ return None
179
+ until_reset = meters.weekly_resets_at - now
180
+ if (
181
+ until_reset > ALL_CLAUDE_FLOORS["main_spend_window"]
182
+ or meters.weekly_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["main_weekly_used_ceiling"]
183
+ or meters.session_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["main_session_used_ceiling"]
184
+ ):
185
+ return None
186
+ hours = int(until_reset.total_seconds() // SECONDS_PER_HOUR)
187
+ return f"main week resets in {hours} hours with {meters.weekly_percent_left:.0f}% left"
188
+
189
+
190
+ def _claude_extra_tier(meters: Meters | None) -> str | None:
191
+ if meters is None or meters.tightest_percent_left is None:
192
+ return None
193
+ if (
194
+ meters.weekly_percent_left is not None
195
+ and meters.weekly_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_weekly_used_ceiling"]
196
+ ) or (
197
+ meters.session_percent_left is not None
198
+ and meters.session_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_session_used_ceiling"]
199
+ ):
200
+ return None
201
+ return "normal"
202
+
203
+
204
+ def _codex_tier(meters: Meters | None) -> str | None:
205
+ if meters is None or meters.tightest_percent_left is None:
206
+ return None
207
+ if meters.tightest_percent_left > ALL_CODEX_FLOORS["normal_minimum_left"]:
208
+ return "normal"
209
+ if (
210
+ meters.tightest_percent_left > ALL_CODEX_FLOORS["luna_stop_left"]
211
+ and (
212
+ meters.session_percent_left is None
213
+ or meters.session_percent_left >= ALL_CODEX_FLOORS["luna_short_minimum_left"]
214
+ )
215
+ ):
216
+ return "luna"
217
+ return None
218
+
219
+
220
+ def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
221
+ meters = reading.meters
222
+ if meters is None:
223
+ return None
224
+ if is_spent:
225
+ return min((reset for reset in (meters.session_resets_at, meters.weekly_resets_at) if reset is not None), default=None)
226
+ if reading.account.product is Product.CLAUDE:
227
+ if reading.account.is_main:
228
+ return meters.weekly_resets_at
229
+ blocking = (
230
+ (meters.session_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_session_used_ceiling"], meters.session_resets_at),
231
+ (meters.weekly_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_weekly_used_ceiling"], meters.weekly_resets_at),
232
+ )
233
+ else:
234
+ blocking = (
235
+ (meters.session_percent_left, ALL_CODEX_FLOORS["luna_short_minimum_left"], meters.session_resets_at),
236
+ (meters.weekly_percent_left, ALL_CODEX_FLOORS["luna_stop_left"], meters.weekly_resets_at),
237
+ )
238
+ return max((reset for room, floor, reset in blocking if room is not None and room <= floor and reset is not None), default=None)
239
+
240
+
241
+ def _rank_key(reading: Reading) -> float:
242
+ room = reading.meters.tightest_percent_left if reading.meters else None
243
+ return -(room if room is not None else -1.0)
244
+
245
+
246
+ def _main_has_room(meters: Meters | None) -> bool:
247
+ return bool(
248
+ meters is not None
249
+ and meters.session_percent_left is not None
250
+ and meters.weekly_percent_left is not None
251
+ and meters.session_percent_left > FULL_PERCENT - ALL_CLAUDE_FLOORS["main_session_used_ceiling"]
252
+ and meters.weekly_percent_left > FULL_PERCENT - ALL_CLAUDE_FLOORS["main_weekly_used_ceiling"]
253
+ )
254
+
255
+
256
+ def _choose_claude(
257
+ all_available: Sequence[Reading], now: datetime, preferred_command: str | None, has_main_guard: bool
258
+ ) -> Decision | None:
259
+ for each_reading in all_available:
260
+ is_bound = preferred_command in (each_reading.account.command, each_reading.account.name)
261
+ has_room = _main_has_room(each_reading.meters) if each_reading.account.is_main else bool(_claude_extra_tier(each_reading.meters))
262
+ if preferred_command is not None and is_bound and has_room:
263
+ return Decision("run", each_reading.account, None, "resume affinity", TIER_NORMAL)
264
+ main = next((each_reading for each_reading in all_available if each_reading.account.is_main), None)
265
+ main_reason = _main_reason(main.meters, now) if main and has_main_guard else None
266
+ if main is not None and main_reason is not None:
267
+ return Decision("run", main.account, None, main_reason, TIER_NORMAL)
268
+ extras = sorted((each_reading for each_reading in all_available if not each_reading.account.is_main), key=_rank_key)
269
+ for each_reading in extras:
270
+ if _claude_extra_tier(each_reading.meters):
271
+ return Decision("run", each_reading.account, None, f"{each_reading.account.name} has {each_reading.meters.tightest_percent_left:g}% left", TIER_NORMAL)
272
+ return None
273
+
274
+
275
+ def _choose_codex(all_available: Sequence[Reading]) -> Decision | None:
276
+ all_candidates = sorted(all_available, key=_rank_key)
277
+ for each_tier in (TIER_NORMAL, TIER_LUNA):
278
+ chosen = _choose_codex_tier(all_candidates, each_tier)
279
+ if chosen is not None:
280
+ return chosen
281
+ return None
282
+
283
+
284
+ def _choose_codex_tier(all_candidates: Sequence[Reading], tier: str) -> Decision | None:
285
+ for each_reading in all_candidates:
286
+ if _codex_tier(each_reading.meters) == tier:
287
+ return Decision("run", each_reading.account, None, f"{each_reading.account.name} has {each_reading.meters.tightest_percent_left:g}% left", tier)
288
+ return None
289
+
290
+
291
+ def _wait_decision(
292
+ all_readings: Sequence[Reading], now: datetime, all_spent_accounts: frozenset[Account], all_spent_resets: Mapping[Account, datetime]
293
+ ) -> Decision:
294
+ all_resets = []
295
+ for each_reading in all_readings:
296
+ is_spent = each_reading.account in all_spent_accounts
297
+ reset = all_spent_resets.get(each_reading.account) if is_spent else None
298
+ reset = reset or _account_reset(each_reading, is_spent)
299
+ all_resets.append(reset if reset is not None and reset > now else now + timedelta(hours=1))
300
+ soonest = min(all_resets, default=now + timedelta(hours=1))
301
+ return Decision("wait", None, soonest, f"no account has room; next reset at {utc_time_text(soonest)}", "wait")