Skip to content

Commit 70c8953

Browse files
izzzzziclaude
andcommitted
feat: add Codex account switcher with auto-rotation
Add a full-featured Codex account switching mechanism that monitors usage limits and automatically rotates the active auth.json when the current account approaches its rate limit (>= 90%). Backend (backend/codex_switcher.py): - Read account pool from <DATA_ROOT>/codex (codex-*.json files) - Detect active account by matching current auth.json tokens - Fetch usage via https://chatgpt.com/backend-api/wham/usage - Auto-refresh expiring access tokens via OpenAI OAuth endpoint - Background scheduler with configurable interval - Atomic file writes with cross-platform path resolution (CODEX_HOME) Integration (backend/ui_facade.py, backend/rpc_server.py, backend/dto.py): - New DTOs: CodexAccountRow, CodexSwitcherStatusDTO - New RPC methods: codex_switcher.refresh, codex_switcher.switch_now, codex_switcher.pick_first_ready - New settings: CODEX_SWITCHER_ENABLED, CODEX_SWITCHER_INTERVAL_MINUTES UI (ui/src/menus/, ui/src/screens/MainScreen.ts): - "Свитч аккаунтов" menu section with account table - Manual refresh, manual switch, pick-first-ready actions - Auto-switch toggle and interval configuration via settings Tests: - 10 Python tests covering auto-switch, token refresh, 401 retry, pick-first-ready, missing refresh_token, threshold logic - 11 UI tests covering menu options, table rendering, hints, picker states, parent navigation Screenshots updated to reflect new UI section. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ddfe7b6 commit 70c8953

13 files changed

Lines changed: 1472 additions & 11 deletions

File tree

backend/codex_switcher.py

Lines changed: 571 additions & 0 deletions
Large diffs are not rendered by default.

backend/dto.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,37 @@ def from_account(
9090
)
9191

9292

93+
@dataclass
94+
class CodexAccountRow:
95+
email: str
96+
is_active: bool
97+
primary_used_percent: float | None
98+
primary_resets_at: str | None
99+
secondary_used_percent: float | None
100+
secondary_resets_at: str | None
101+
usage_status: str
102+
token_status: str
103+
last_checked_at: str | None
104+
last_error: str | None
105+
near_limit: bool
106+
107+
108+
@dataclass
109+
class CodexSwitcherStatusDTO:
110+
enabled: bool
111+
interval_minutes: int
112+
last_run_at: str | None
113+
last_switch_at: str | None
114+
active_email: str | None
115+
last_error: str | None
116+
117+
93118
@dataclass
94119
class AppStateDTO:
95120
admins: list[AdminRow]
96121
workers: list[WorkerRow]
122+
codex_accounts: list[CodexAccountRow] | None = None
123+
codex_switcher_status: CodexSwitcherStatusDTO | None = None
97124

98125
def to_dict(self) -> dict:
99126
return asdict(self)

backend/rpc_server.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ def _run_job(self, title: str, fn):
5959
("BOOMLIFY_TIME", "Время жизни ящика"),
6060
("SLOT_MAIL_PROVIDER", "Провайдер почты для слотов"),
6161
("MAIL_PROVIDER", "Провайдер почты для админов"),
62+
("CODEX_SWITCHER_ENABLED", "Автосвитч Codex"),
63+
("CODEX_SWITCHER_INTERVAL_MINUTES", "Интервал проверки Codex (мин)"),
6264
]
6365

6466
@staticmethod
@@ -69,16 +71,30 @@ def _get_settings(self) -> dict[str, Any]:
6971
items = []
7072
for key, label in self._SETTINGS_KEYS:
7173
value = os.environ.get(key, "")
72-
masked = ""
73-
if value:
74-
masked = value[:4] + "***" + value[-4:] if len(value) > 12 else "***"
74+
masked = self._mask_setting_value(key, value)
7575
items.append({"key": key, "label": label, "masked": masked})
7676
return {"items": items, "path": str(self._settings_path())}
7777

78+
@staticmethod
79+
def _mask_setting_value(key: str, value: str) -> str:
80+
if not value:
81+
return ""
82+
if "KEY" in key:
83+
return value[:4] + "***" + value[-4:] if len(value) > 12 else "***"
84+
return value
85+
7886
def _set_setting(self, key: str, value: str) -> None:
7987
allowed = {k for k, _ in self._SETTINGS_KEYS}
8088
if key not in allowed:
8189
raise RPCError(-32602, "Unknown setting", {"key": key})
90+
if key == "CODEX_SWITCHER_ENABLED" and value not in {"", "0", "1", "true", "false", "yes", "no", "on", "off"}:
91+
raise RPCError(-32602, "Invalid setting", {"details": "CODEX_SWITCHER_ENABLED expects boolean-like value"})
92+
if key == "CODEX_SWITCHER_INTERVAL_MINUTES":
93+
try:
94+
if int(value) <= 0:
95+
raise ValueError
96+
except ValueError:
97+
raise RPCError(-32602, "Invalid setting", {"details": "CODEX_SWITCHER_INTERVAL_MINUTES expects integer > 0"}) from None
8298

8399
env_path = self._settings_path()
84100
env_path.parent.mkdir(parents=True, exist_ok=True)
@@ -226,6 +242,16 @@ def _handle_request(self, req: RPCRequest) -> dict[str, Any]:
226242
self._set_setting(key, value)
227243
return make_success_response(req.request_id, {"ok": True})
228244

245+
if m == "codex_switcher.refresh":
246+
return make_success_response(req.request_id, self.facade.refresh_codex_switcher())
247+
248+
if m == "codex_switcher.switch_now":
249+
email = self._as_str_param(p, "email")
250+
return make_success_response(req.request_id, self.facade.switch_codex_account(email))
251+
252+
if m == "codex_switcher.pick_first_ready":
253+
return make_success_response(req.request_id, self.facade.pick_first_codex_account())
254+
229255
if m == "shutdown":
230256
self.jobs.wait_all(timeout=10)
231257
self.facade.shutdown()

backend/ui_facade.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
from . import DATA_ROOT, PROJECT_ROOT
1010
from .account_store import AccountStore
11-
from .dto import AdminRow, AppStateDTO, WorkerRow
11+
from .codex_switcher import CodexSwitcherService
12+
from .dto import AdminRow, AppStateDTO, CodexAccountRow, CodexSwitcherStatusDTO, WorkerRow
1213
from .mail import Mailbox, create_provider_for_mailbox
1314
from .openai_web_auth import (
1415
close_browser as close_br,
@@ -31,9 +32,14 @@ def _has_profile_files(profile_dir: Path) -> bool:
3132

3233

3334
class UIFacade:
34-
def __init__(self, store: AccountStore | None = None) -> None:
35+
def __init__(
36+
self,
37+
store: AccountStore | None = None,
38+
codex_switcher: CodexSwitcherService | None = None,
39+
) -> None:
3540
self.store = store or AccountStore()
3641
self.manager: SlotManager | None = None
42+
self.codex_switcher = codex_switcher or CodexSwitcherService()
3743
self.bootstrap()
3844

3945
def bootstrap(self) -> None:
@@ -47,8 +53,10 @@ def bootstrap(self) -> None:
4753

4854
self.store.doctor()
4955
self.sync_codex_files()
56+
self.codex_switcher.start()
5057

5158
def shutdown(self) -> None:
59+
self.codex_switcher.stop()
5260
self.sync_codex_files()
5361

5462
def sync_codex_files(self) -> None:
@@ -66,6 +74,7 @@ def sync_codex_files(self) -> None:
6674
dst.write_text(f.read_text())
6775

6876
def get_state(self) -> dict[str, Any]:
77+
codex_state = self.codex_switcher.get_state()
6978
raw_admins = self.store.list_admins()
7079
raw_workers = self.store.list_workers()
7180
admins = [
@@ -82,7 +91,31 @@ def get_state(self) -> dict[str, Any]:
8291
)
8392
for w in raw_workers
8493
]
85-
return AppStateDTO(admins=admins, workers=workers).to_dict()
94+
codex_accounts = [CodexAccountRow(**item) for item in codex_state.get("items", [])]
95+
status_raw = codex_state.get("status", {})
96+
status = CodexSwitcherStatusDTO(
97+
enabled=bool(status_raw.get("enabled")),
98+
interval_minutes=int(status_raw.get("interval_minutes") or 15),
99+
last_run_at=status_raw.get("last_run_at"),
100+
last_switch_at=status_raw.get("last_switch_at"),
101+
active_email=status_raw.get("active_email"),
102+
last_error=status_raw.get("last_error"),
103+
)
104+
return AppStateDTO(
105+
admins=admins,
106+
workers=workers,
107+
codex_accounts=codex_accounts,
108+
codex_switcher_status=status,
109+
).to_dict()
110+
111+
def refresh_codex_switcher(self) -> dict[str, Any]:
112+
return self.codex_switcher.refresh_now(auto_switch=False)
113+
114+
def switch_codex_account(self, email: str) -> dict[str, Any]:
115+
return self.codex_switcher.switch_now(email)
116+
117+
def pick_first_codex_account(self) -> dict[str, Any]:
118+
return self.codex_switcher.pick_first_ready()
86119

87120
def add_admin(self, email: str, password: str) -> dict[str, Any]:
88121
admin = self.store.add_admin(email, password)

img/1.png

143 KB
Loading

img/2.png

-5.42 KB
Loading

img/3.png

-12.1 KB
Loading

img/4.png

-123 KB
Loading

0 commit comments

Comments
 (0)