From 78784dc9813ba03ee3d761656331ef55fa453108 Mon Sep 17 00:00:00 2001 From: Giorgo Lazaridis Date: Sun, 25 Oct 2026 19:28:24 +0100 Subject: [PATCH 1/2] feat(codex): auto honors autoswitch.strategy and the per-window thresholds `ccswap codex auto` used one threshold and always picked the most headroom; `autoswitch.strategy`, `threshold5h` and `threshold7d` only affected Claude. Now the 5h gate applies to Codex's 5-hour window and the 7d gate to its Weekly window, through the same binding_pct gate math the Claude engine uses, so a candidate must also clear every gate. With `consume-first` the engine moves, even below the threshold, to an account whose Weekly window resets strictly sooner and still has room, and at the limit it ranks candidates by soonest Weekly reset. Cooldown applies to those moves like to proactive ones. With default settings the decisions, events and messages are unchanged. --- docs/auto-switching.md | 2 +- docs/codex.md | 2 +- src/claude_swap/codex_autoswitch.py | 92 +++++++++-- src/claude_swap/settings.py | 2 +- tests/test_codex_autoswitch.py | 232 ++++++++++++++++++++++++++++ 5 files changed, 313 insertions(+), 17 deletions(-) diff --git a/docs/auto-switching.md b/docs/auto-switching.md index dae93a4..d776a7c 100644 --- a/docs/auto-switching.md +++ b/docs/auto-switching.md @@ -21,7 +21,7 @@ ccswap auto --strategy consume-first # burn the soonest-resetting account firs - An account whose refresh token has died is quarantined and reported until you either log in with it and re-run `ccswap add --slot N`, or replace its stored credentials from a known-good export — a plain `ccswap import backup.cswap` replaces dead-token slots on its own (`--force` is still required to replace other existing accounts; note a stale export can carry an already-superseded token). API-key accounts are never rotated onto unless you pass `--include-api-key-accounts`. - To hold an account out of rotation yourself — a work account you don't want touched, one you're resting — run `ccswap disable `; `ccswap enable ` puts it back. Disabled accounts are skipped by auto-switch, bare `ccswap switch`, and the `best` / `next-available` strategies, but stay fully managed and remain a valid explicit `ccswap switch ` target. They show a `(disabled)` marker in `ccswap list`, in the [TUI](dashboard.md#interactive-dashboard-tui), and in the [menu bar](dashboard.md#menu-bar-macos) — both of which also let you toggle the state in place (TUI: menu → *Disable / enable account…*; menu bar: *Disable / enable account*). - By default only the account-wide 5h/7d windows drive switching, and they share one threshold. If you work on one model and hit its **weekly per-model limit** first (e.g. Fable), add `--model Fable` (or `ccswap config set autoswitch.model Fable`) to fold that model's window into the decision, so it switches off an account whose model quota is spent even while its 5h/7d windows still have room. -- **Separate gates** (`5h`, weekly `7d`, and any named model such as Fable): any one of them reaching its own threshold switches, even if the others are fine. Unset gates inherit `autoswitch.threshold`. +- **Separate gates** (`5h`, weekly `7d`, and any named model such as Fable): any one of them reaching its own threshold switches, even if the others are fine. Unset gates inherit `autoswitch.threshold`. Codex auto-switching also honors `autoswitch.strategy`, `autoswitch.threshold5h`, and `autoswitch.threshold7d` (its Weekly window); model gates do not apply to Codex. ```bash ccswap config set autoswitch.threshold5h 85 # session window diff --git a/docs/codex.md b/docs/codex.md index 026db66..7b7e5e9 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -18,6 +18,6 @@ ccswap codex auto --once # switch when active quota reaches the thres ccswap codex remove 2 ``` -Codex switching preserves the rest of `CODEX_HOME` (configuration, skills, sessions, and history) and replaces only `auth.json`. Restart Codex after switching so its running process loads the selected login. For ChatGPT-backed file logins, the dashboard reads the same read-only Codex rate-limit endpoints used by Codex and shows the weekly window with a live reset countdown, the workspace/purchased credit balance, the per-user credit allowance when the service reports one, plus the number of banked resets and the earliest available reset's expiry. Credits, per-user allowances, and banked resets are shown separately because they are different resources. Window labels are derived from the duration reported by Codex, so older accounts that still return a 5-hour window remain correctly labelled. Each account is labelled by its ChatGPT plan (`Codex Team`, `Codex Pro`, `Codex Plus`, …) — read from the login token, the closest local equivalent to Claude Code's org name, since Codex stores no workspace name on disk. API-key accounts have no ChatGPT subscription quota, so they remain status-only. `ccswap codex auto` uses the same threshold, cooldown, `--once`, `--dry-run`, and JSON event controls as Claude auto-switching; it prepares the account for the next Codex launch and reports when a restart is needed. +Codex switching preserves the rest of `CODEX_HOME` (configuration, skills, sessions, and history) and replaces only `auth.json`. Restart Codex after switching so its running process loads the selected login. For ChatGPT-backed file logins, the dashboard reads the same read-only Codex rate-limit endpoints used by Codex and shows the weekly window with a live reset countdown, the workspace/purchased credit balance, the per-user credit allowance when the service reports one, plus the number of banked resets and the earliest available reset's expiry. Credits, per-user allowances, and banked resets are shown separately because they are different resources. Window labels are derived from the duration reported by Codex, so older accounts that still return a 5-hour window remain correctly labelled. Each account is labelled by its ChatGPT plan (`Codex Team`, `Codex Pro`, `Codex Plus`, …) — read from the login token, the closest local equivalent to Claude Code's org name, since Codex stores no workspace name on disk. API-key accounts have no ChatGPT subscription quota, so they remain status-only. `ccswap codex auto` honors `autoswitch.strategy` (`best` or `consume-first`) and the per-window thresholds `autoswitch.threshold5h` and `autoswitch.threshold7d` (Codex Weekly), and uses the same threshold, cooldown, `--once`, `--dry-run`, and JSON event controls as Claude auto-switching; it prepares the account for the next Codex launch and reports when a restart is needed. Codex must use its documented file credential store. If your `~/.codex/config.toml` says `cli_auth_credentials_store = "keyring"`, change it to `"file"`, run `codex login`, then add the account. This deliberate restriction avoids writing a guessed OS-keyring entry. diff --git a/src/claude_swap/codex_autoswitch.py b/src/claude_swap/codex_autoswitch.py index aa20429..4578216 100644 --- a/src/claude_swap/codex_autoswitch.py +++ b/src/claude_swap/codex_autoswitch.py @@ -15,6 +15,7 @@ from collections.abc import Callable from pathlib import Path +from claude_swap import oauth from claude_swap.autoswitch import ( AllExhaustedEvent, AutoSwitchEvent, @@ -27,7 +28,8 @@ ) from claude_swap.codex import CodexAccountSwitcher, _codex_restart_hint from claude_swap.locking import FileLock -from claude_swap.settings import AutoSwitchSettings, atomic_write_json +from claude_swap.poll_policy import parse_reset_ts +from claude_swap.settings import AutoSwitchSettings, atomic_write_json, gate_thresholds STATE_FILENAME = "codex_autoswitch_state.json" STATE_SCHEMA_VERSION = 1 @@ -37,6 +39,23 @@ def _ref(number: str, email: str) -> dict: return {"number": int(number), "email": email} +def _codex_gates(settings: AutoSwitchSettings) -> dict[str, float]: + # Claude's 7d gate maps to Codex Weekly; model gates do not apply to Codex. + return { + ("weekly" if label == "7d" else label): pct + for label, pct in gate_thresholds(settings).overrides.items() + if label in ("5h", "7d") + } + + +def _weekly_reset(usage: dict | None, now: float) -> float | None: + for label, _, resets_at in oauth.relevant_windows(usage): + if label == "Weekly": + ts = parse_reset_ts(resets_at) + return ts if ts is not None and ts > now else None + return None + + class CodexAutoSwitchEngine: """Switch saved Codex accounts once their binding quota reaches a limit.""" @@ -141,19 +160,32 @@ def _tick(self) -> TickOutcome: ) ) return TickOutcome.NO_ACTION - active_pct = 100.0 - active_headroom - if active_pct < self.settings.threshold: - self._emit( - NoSwitchEvent( - reason="below-threshold", - detail=f"{active_pct:.0f}% < {self.settings.threshold:.0f}%", - ) + overrides = _codex_gates(self.settings) + policy = { + number: binding_pct( + value, + gate_thresholds=overrides, + default_threshold=self.settings.threshold, ) - return TickOutcome.NO_ACTION - trigger = "at-limit" if active_headroom <= 0 else "proactive" + for number, value in usage.items() + } + active_pct = policy[current] + below_threshold = active_pct < self.settings.threshold + now = self.clock() + resets = {number: _weekly_reset(value, now) for number, value in usage.items()} + active_reset = resets[current] + + if below_threshold: + if self.settings.strategy != "consume-first" or active_reset is None: + return self._below_threshold(usage[current], active_headroom, overrides) + # Burn the soonest-expiring weekly quota first. Only a strictly sooner + # reset moves, so consume-first cannot ping-pong between accounts. + trigger = "consume-first" + else: + trigger = "at-limit" if active_headroom <= 0 else "proactive" state = self._read_state() - if trigger == "proactive" and self._in_cooldown(state): + if trigger in ("proactive", "consume-first") and self._in_cooldown(state): self._emit(NoSwitchEvent(reason="cooldown")) return TickOutcome.NO_ACTION @@ -166,6 +198,8 @@ def _tick(self) -> TickOutcome: and not account.disabled ] if not candidates: + if below_threshold: + return self._below_threshold(usage[current], active_headroom, overrides) self._emit(NoSwitchEvent(reason="no-candidates")) return TickOutcome.BLOCKED @@ -175,16 +209,30 @@ def _tick(self) -> TickOutcome: candidate_headroom = headroom[account.number] if candidate_headroom is None or candidate_headroom <= 0: continue - candidate_pct = 100.0 - candidate_headroom + candidate_pct = policy[account.number] if trigger == "proactive" and ( candidate_pct > hysteresis_bar - or candidate_headroom <= active_headroom + or candidate_pct >= active_pct ): continue + if trigger == "consume-first": + reset = resets[account.number] + if ( + candidate_pct >= self.settings.threshold + or reset is None + or reset >= active_reset + ): + continue qualifying.append((candidate_headroom, account.number)) qualifying.sort(reverse=True) + if self.settings.strategy == "consume-first": + qualifying.sort( + key=lambda item: resets[item[1]] or float("inf") + ) if not qualifying: + if below_threshold: + return self._below_threshold(usage[current], active_headroom, overrides) if all( candidate_headroom is not None and candidate_headroom <= 0 for candidate_headroom in (headroom[account.number] for account in candidates) @@ -197,6 +245,22 @@ def _tick(self) -> TickOutcome: target = accounts[qualifying[0][1]] return self._perform(current, active.email, target.number, target.email, trigger) + def _below_threshold( + self, usage: dict, active_headroom: float, overrides: dict[str, float] + ) -> TickOutcome: + detail = f"{100.0 - active_headroom:.0f}% < {self.settings.threshold:.0f}%" + if overrides: + label, pct, _ = min( + oauth.relevant_windows(usage), + key=lambda w: ( + overrides.get(w[0].lower(), self.settings.threshold) - w[1], -w[1] + ), + ) + threshold = overrides.get(label.lower(), self.settings.threshold) + detail = f"{label} {pct:.0f}% < {threshold:.0f}%" + self._emit(NoSwitchEvent(reason="below-threshold", detail=detail)) + return TickOutcome.NO_ACTION + def _perform( self, current: str, @@ -219,7 +283,7 @@ def _perform( with self._state_lock(): state = self._read_state() - if trigger == "proactive" and self._in_cooldown(state): + if trigger in ("proactive", "consume-first") and self._in_cooldown(state): self._emit(NoSwitchEvent(reason="cooldown")) return TickOutcome.NO_ACTION result = self.switcher.switch_to(target, json_output=True) diff --git a/src/claude_swap/settings.py b/src/claude_swap/settings.py index a07a314..b3fb221 100644 --- a/src/claude_swap/settings.py +++ b/src/claude_swap/settings.py @@ -201,7 +201,7 @@ def default(self): ), SettingSpec( "autoswitch", "threshold7d", "threshold_7d", "optional_float", 1.0, 99.9, - help="Claude weekly (7d) gate; unset uses autoswitch.threshold (Codex Weekly always does)", + help="Claude 7d and Codex Weekly gate; unset uses autoswitch.threshold", ), SettingSpec( "autoswitch", "modelThresholds", "model_thresholds", "model_thresholds", diff --git a/tests/test_codex_autoswitch.py b/tests/test_codex_autoswitch.py index 27ca43a..55eb5af 100644 --- a/tests/test_codex_autoswitch.py +++ b/tests/test_codex_autoswitch.py @@ -2,6 +2,9 @@ from __future__ import annotations +import json +from dataclasses import replace +from datetime import datetime, timezone from pathlib import Path from claude_swap.autoswitch import TickOutcome @@ -124,3 +127,232 @@ def test_dry_run_reports_a_switch_without_changing_the_active_account(tmp_path): assert switcher.switched_to == [] assert events[-1].kind == "switch" assert events[-1].dry_run + + +def _weekly_account(number, pct, reset, *, active=False, weekly_pct=20): + account = _account(number, pct, active=active) + resets_at = ( + datetime.fromtimestamp(reset, timezone.utc).isoformat() + if reset is not None else None + ) + return replace( + account, + usage=UsageEntry(last_good={ + "five_hour": {"pct": pct}, + "weekly": {"pct": weekly_pct, "resets_at": resets_at}, + }), + ) + + +def test_per_window_gates_trigger_and_keep_absolute_poll_headroom(tmp_path, monkeypatch): + monkeypatch.setattr("claude_swap.codex.is_codex_running", lambda: False) + for window in ("5h", "7d"): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 75 if window == "5h" else 20, 3000, + active=True, weekly_pct=75 if window == "7d" else 20), + _weekly_account("2", 10, 2000, weekly_pct=10), + )) + settings = AutoSwitchSettings(threshold=90, **{ + "threshold_5h" if window == "5h" else "threshold_7d": 75, + }) + events = [] + engine = CodexAutoSwitchEngine( + switcher, settings, events.append, dry_run=True, clock=lambda: 1000 + ) + assert engine.tick() is TickOutcome.SWITCHED + assert events[0].headroom["1"] == 25 + assert events[-1].trigger == "proactive" + + +def test_proactive_candidate_must_clear_each_gate(tmp_path): + for window in ("5h", "7d"): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 95, 3000, active=True), + _weekly_account("2", 71 if window == "5h" else 10, 2000, + weekly_pct=71 if window == "7d" else 10), + )) + settings = AutoSwitchSettings(threshold=90, **{ + "threshold_5h" if window == "5h" else "threshold_7d": 70, + }) + events = [] + engine = CodexAutoSwitchEngine( + switcher, settings, events.append, clock=lambda: 1000 + ) + assert engine.tick() is TickOutcome.BLOCKED + assert not switcher.switched_to + + +def test_consume_first_below_threshold_switches_even_with_less_headroom(tmp_path, monkeypatch): + monkeypatch.setattr("claude_swap.codex.is_codex_running", lambda: False) + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 20, 3000, active=True), + _weekly_account("2", 60, 2000), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.SWITCHED + assert switcher.switched_to == ["2"] + assert events[-1].trigger == "consume-first" + + +def test_consume_first_below_threshold_holds_without_eligible_candidate(tmp_path): + cases = ( + (2000, 3000, 20, 20), # active resets first + (2000, 2000, 20, 20), # strictly sooner is required + (None, 2000, 20, 20), + (900, 2000, 20, 20), # past active reset is unknown + (3000, None, 20, 20), + (3000, 900, 20, 20), # past candidate reset is unknown + (3000, 1000, 20, 20), # reset must be in the future + (3000, 2000, 100, 20), # exhausted candidate + (3000, 2000, 20, 70), # candidate at its Weekly gate + (3000, 2000, 70, 20), # candidate at its 5h gate + ) + for active_reset, candidate_reset, pct, weekly_pct in cases: + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 20, active_reset, active=True), + _weekly_account("2", pct, candidate_reset, weekly_pct=weekly_pct), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first", + threshold_5h=70, threshold_7d=70), + events.append, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.NO_ACTION + assert not switcher.switched_to + assert events[-1].reason == "below-threshold" + + +def test_consume_first_without_candidates_is_not_blocked(tmp_path): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 20, 3000, active=True), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.NO_ACTION + assert events[-1].reason == "below-threshold" + + +def test_consume_first_obeys_cooldown(tmp_path): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 20, 3000, active=True), + _weekly_account("2", 30, 2000), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, clock=lambda: 1000, + ) + engine.state_path.write_text(json.dumps({"lastSwitchAt": 999})) + assert engine.tick() is TickOutcome.NO_ACTION + assert not switcher.switched_to + assert events[-1].reason == "cooldown" + + +def test_consume_first_ranks_future_weekly_resets_before_headroom(tmp_path, monkeypatch): + monkeypatch.setattr("claude_swap.codex.is_codex_running", lambda: False) + for active_pct in (95, 100): + for unknown_reset in (None, 900): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", active_pct, 4000, active=True), + _weekly_account("2", 10, unknown_reset), + _weekly_account("3", 30, 3000), + _weekly_account("4", 60, 2000), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, dry_run=True, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.SWITCHED + assert events[-1].to_ref["number"] == 4 + assert not switcher.switched_to + + +def test_consume_first_dry_run_does_not_write_state(tmp_path, monkeypatch): + monkeypatch.setattr("claude_swap.codex.is_codex_running", lambda: False) + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 20, 3000, active=True), + _weekly_account("2", 30, 2000), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, dry_run=True, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.SWITCHED + assert events[-1].trigger == "consume-first" + assert events[-1].dry_run + assert not switcher.switched_to + assert not engine.state_path.exists() + + +def test_consume_first_rechecks_cooldown_under_lock(tmp_path, monkeypatch): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 20, 3000, active=True), + _weekly_account("2", 30, 2000), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, clock=lambda: 1000, + ) + states = iter(({}, {"lastSwitchAt": 999})) + monkeypatch.setattr(engine, "_read_state", lambda: next(states)) + assert engine.tick() is TickOutcome.NO_ACTION + assert not switcher.switched_to + assert events[-1].reason == "cooldown" + + +def test_consume_first_unknown_resets_fall_back_to_headroom(tmp_path, monkeypatch): + monkeypatch.setattr("claude_swap.codex.is_codex_running", lambda: False) + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 100, 3000, active=True), + _weekly_account("2", 60, None), + _weekly_account("3", 10, 900), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, dry_run=True, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.SWITCHED + assert events[-1].to_ref["number"] == 3 + + +def test_below_threshold_detail_reports_the_tightest_gate(tmp_path): + for strategy in ("best", "consume-first"): + for five_hour, weekly, expected in ( + (70, 80, "Weekly 80% < 85%"), + (86, 80, "5h 86% < 90%"), + (85, 80, "5h 85% < 90%"), # equal slack: higher usage binds + ): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", five_hour, 3000, active=True, weekly_pct=weekly), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, + AutoSwitchSettings(strategy=strategy, threshold=90, threshold_7d=85), + events.append, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.NO_ACTION + assert events[-1].reason == "below-threshold" + assert events[-1].detail == expected + + +def test_below_threshold_detail_without_overrides_keeps_historical_text(tmp_path): + switcher = FakeCodexSwitcher(tmp_path, (_account("1", 75.4, active=True),)) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(threshold=90), events.append, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.NO_ACTION + assert events[-1].detail == "75% < 90%" From 10ae8b2a0fc44c5457875267ad73f4a6b640d3bc Mon Sep 17 00:00:00 2001 From: Giorgo Lazaridis Date: Mon, 26 Oct 2026 16:56:07 +0100 Subject: [PATCH 2/2] feat(codex): honor auto strategy and per-window thresholds --- CHANGELOG.md | 9 + docs/auto-switching.md | 2 +- docs/codex.md | 2 +- src/claude_swap/codex_autoswitch.py | 94 +++++++++-- src/claude_swap/settings.py | 2 +- tests/test_codex_autoswitch.py | 251 ++++++++++++++++++++++++++++ 6 files changed, 342 insertions(+), 18 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..27e40b3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes are documented in this file using Keep a Changelog 1.1.0. + +## [Unreleased] + +### Added + +- Honor shared Codex auto-switch strategies and per-window thresholds. diff --git a/docs/auto-switching.md b/docs/auto-switching.md index dae93a4..d776a7c 100644 --- a/docs/auto-switching.md +++ b/docs/auto-switching.md @@ -21,7 +21,7 @@ ccswap auto --strategy consume-first # burn the soonest-resetting account firs - An account whose refresh token has died is quarantined and reported until you either log in with it and re-run `ccswap add --slot N`, or replace its stored credentials from a known-good export — a plain `ccswap import backup.cswap` replaces dead-token slots on its own (`--force` is still required to replace other existing accounts; note a stale export can carry an already-superseded token). API-key accounts are never rotated onto unless you pass `--include-api-key-accounts`. - To hold an account out of rotation yourself — a work account you don't want touched, one you're resting — run `ccswap disable `; `ccswap enable ` puts it back. Disabled accounts are skipped by auto-switch, bare `ccswap switch`, and the `best` / `next-available` strategies, but stay fully managed and remain a valid explicit `ccswap switch ` target. They show a `(disabled)` marker in `ccswap list`, in the [TUI](dashboard.md#interactive-dashboard-tui), and in the [menu bar](dashboard.md#menu-bar-macos) — both of which also let you toggle the state in place (TUI: menu → *Disable / enable account…*; menu bar: *Disable / enable account*). - By default only the account-wide 5h/7d windows drive switching, and they share one threshold. If you work on one model and hit its **weekly per-model limit** first (e.g. Fable), add `--model Fable` (or `ccswap config set autoswitch.model Fable`) to fold that model's window into the decision, so it switches off an account whose model quota is spent even while its 5h/7d windows still have room. -- **Separate gates** (`5h`, weekly `7d`, and any named model such as Fable): any one of them reaching its own threshold switches, even if the others are fine. Unset gates inherit `autoswitch.threshold`. +- **Separate gates** (`5h`, weekly `7d`, and any named model such as Fable): any one of them reaching its own threshold switches, even if the others are fine. Unset gates inherit `autoswitch.threshold`. Codex auto-switching also honors `autoswitch.strategy`, `autoswitch.threshold5h`, and `autoswitch.threshold7d` (its Weekly window); model gates do not apply to Codex. ```bash ccswap config set autoswitch.threshold5h 85 # session window diff --git a/docs/codex.md b/docs/codex.md index 2f4e19d..973c8fc 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -23,6 +23,6 @@ ccswap codex remove 2 Usage-aware strategies consider switchable, enabled ChatGPT accounts only; API-key accounts have no subscription quota and are skipped. -Codex switching preserves the rest of `CODEX_HOME` (configuration, skills, sessions, and history) and replaces only `auth.json`. Restart Codex after switching so its running process loads the selected login. For ChatGPT-backed file logins, the dashboard reads the same read-only Codex rate-limit endpoints used by Codex and shows the weekly window with a live reset countdown, the workspace/purchased credit balance, the per-user credit allowance when the service reports one, plus the number of banked resets and the earliest available reset's expiry. Credits, per-user allowances, and banked resets are shown separately because they are different resources. Window labels are derived from the duration reported by Codex, so older accounts that still return a 5-hour window remain correctly labelled. Each account is labelled by its ChatGPT plan (`Codex Team`, `Codex Pro`, `Codex Plus`, …) — read from the login token, the closest local equivalent to Claude Code's org name, since Codex stores no workspace name on disk. API-key accounts have no ChatGPT subscription quota, so they remain status-only. `ccswap codex auto` uses the same threshold, cooldown, `--once`, `--dry-run`, and JSON event controls as Claude auto-switching; it prepares the account for the next Codex launch and reports when a restart is needed. +Codex switching preserves the rest of `CODEX_HOME` (configuration, skills, sessions, and history) and replaces only `auth.json`. Restart Codex after switching so its running process loads the selected login. For ChatGPT-backed file logins, the dashboard reads the same read-only Codex rate-limit endpoints used by Codex and shows the weekly window with a live reset countdown, the workspace/purchased credit balance, the per-user credit allowance when the service reports one, plus the number of banked resets and the earliest available reset's expiry. Credits, per-user allowances, and banked resets are shown separately because they are different resources. Window labels are derived from the duration reported by Codex, so older accounts that still return a 5-hour window remain correctly labelled. Each account is labelled by its ChatGPT plan (`Codex Team`, `Codex Pro`, `Codex Plus`, …) — read from the login token, the closest local equivalent to Claude Code's org name, since Codex stores no workspace name on disk. API-key accounts have no ChatGPT subscription quota, so they remain status-only. `ccswap codex auto` honors `autoswitch.strategy` (`best` or `consume-first`) and the per-window thresholds `autoswitch.threshold5h` and `autoswitch.threshold7d` (Codex Weekly), and uses the same threshold, cooldown, `--once`, `--dry-run`, and JSON event controls as Claude auto-switching; it prepares the account for the next Codex launch and reports when a restart is needed. Codex must use its documented file credential store. If your `~/.codex/config.toml` says `cli_auth_credentials_store = "keyring"`, change it to `"file"`, run `codex login`, then add the account. This deliberate restriction avoids writing a guessed OS-keyring entry. diff --git a/src/claude_swap/codex_autoswitch.py b/src/claude_swap/codex_autoswitch.py index aa20429..2bb2b36 100644 --- a/src/claude_swap/codex_autoswitch.py +++ b/src/claude_swap/codex_autoswitch.py @@ -15,6 +15,7 @@ from collections.abc import Callable from pathlib import Path +from claude_swap import oauth from claude_swap.autoswitch import ( AllExhaustedEvent, AutoSwitchEvent, @@ -27,7 +28,8 @@ ) from claude_swap.codex import CodexAccountSwitcher, _codex_restart_hint from claude_swap.locking import FileLock -from claude_swap.settings import AutoSwitchSettings, atomic_write_json +from claude_swap.poll_policy import parse_reset_ts +from claude_swap.settings import AutoSwitchSettings, atomic_write_json, gate_thresholds STATE_FILENAME = "codex_autoswitch_state.json" STATE_SCHEMA_VERSION = 1 @@ -37,6 +39,23 @@ def _ref(number: str, email: str) -> dict: return {"number": int(number), "email": email} +def _codex_gates(settings: AutoSwitchSettings) -> dict[str, float]: + # Claude's 7d gate maps to Codex Weekly; model gates do not apply to Codex. + return { + ("weekly" if label == "7d" else label): pct + for label, pct in gate_thresholds(settings).overrides.items() + if label in ("5h", "7d") + } + + +def _weekly_reset(usage: dict | None, now: float) -> float | None: + for label, _, resets_at in oauth.relevant_windows(usage): + if label == "Weekly": + ts = parse_reset_ts(resets_at) + return ts if ts is not None and ts > now else None + return None + + class CodexAutoSwitchEngine: """Switch saved Codex accounts once their binding quota reaches a limit.""" @@ -141,19 +160,32 @@ def _tick(self) -> TickOutcome: ) ) return TickOutcome.NO_ACTION - active_pct = 100.0 - active_headroom - if active_pct < self.settings.threshold: - self._emit( - NoSwitchEvent( - reason="below-threshold", - detail=f"{active_pct:.0f}% < {self.settings.threshold:.0f}%", - ) + overrides = _codex_gates(self.settings) + policy = { + number: binding_pct( + value, + gate_thresholds=overrides, + default_threshold=self.settings.threshold, ) - return TickOutcome.NO_ACTION - trigger = "at-limit" if active_headroom <= 0 else "proactive" + for number, value in usage.items() + } + active_pct = policy[current] + below_threshold = active_pct < self.settings.threshold + now = self.clock() + resets = {number: _weekly_reset(value, now) for number, value in usage.items()} + active_reset = resets[current] + + if below_threshold: + if self.settings.strategy != "consume-first" or active_reset is None: + return self._below_threshold(usage[current], active_headroom, overrides) + # Burn the soonest-expiring weekly quota first. Only a strictly sooner + # reset moves, so consume-first cannot ping-pong between accounts. + trigger = "consume-first" + else: + trigger = "at-limit" if active_headroom <= 0 else "proactive" state = self._read_state() - if trigger == "proactive" and self._in_cooldown(state): + if trigger in ("proactive", "consume-first") and self._in_cooldown(state): self._emit(NoSwitchEvent(reason="cooldown")) return TickOutcome.NO_ACTION @@ -166,6 +198,8 @@ def _tick(self) -> TickOutcome: and not account.disabled ] if not candidates: + if below_threshold: + return self._below_threshold(usage[current], active_headroom, overrides) self._emit(NoSwitchEvent(reason="no-candidates")) return TickOutcome.BLOCKED @@ -175,16 +209,30 @@ def _tick(self) -> TickOutcome: candidate_headroom = headroom[account.number] if candidate_headroom is None or candidate_headroom <= 0: continue - candidate_pct = 100.0 - candidate_headroom + candidate_pct = policy[account.number] if trigger == "proactive" and ( candidate_pct > hysteresis_bar - or candidate_headroom <= active_headroom + or candidate_pct >= active_pct ): continue - qualifying.append((candidate_headroom, account.number)) + if trigger == "consume-first": + reset = resets[account.number] + if ( + candidate_pct >= self.settings.threshold + or reset is None + or reset >= active_reset + ): + continue + qualifying.append((100.0 - candidate_pct, account.number)) qualifying.sort(reverse=True) + if self.settings.strategy == "consume-first": + qualifying.sort( + key=lambda item: resets[item[1]] or float("inf") + ) if not qualifying: + if below_threshold: + return self._below_threshold(usage[current], active_headroom, overrides) if all( candidate_headroom is not None and candidate_headroom <= 0 for candidate_headroom in (headroom[account.number] for account in candidates) @@ -197,6 +245,22 @@ def _tick(self) -> TickOutcome: target = accounts[qualifying[0][1]] return self._perform(current, active.email, target.number, target.email, trigger) + def _below_threshold( + self, usage: dict, active_headroom: float, overrides: dict[str, float] + ) -> TickOutcome: + detail = f"{100.0 - active_headroom:.0f}% < {self.settings.threshold:.0f}%" + if overrides: + label, pct, _ = min( + oauth.relevant_windows(usage), + key=lambda w: ( + overrides.get(w[0].lower(), self.settings.threshold) - w[1], -w[1] + ), + ) + threshold = overrides.get(label.lower(), self.settings.threshold) + detail = f"{label} {pct:.0f}% < {threshold:.0f}%" + self._emit(NoSwitchEvent(reason="below-threshold", detail=detail)) + return TickOutcome.NO_ACTION + def _perform( self, current: str, @@ -219,7 +283,7 @@ def _perform( with self._state_lock(): state = self._read_state() - if trigger == "proactive" and self._in_cooldown(state): + if trigger in ("proactive", "consume-first") and self._in_cooldown(state): self._emit(NoSwitchEvent(reason="cooldown")) return TickOutcome.NO_ACTION result = self.switcher.switch_to(target, json_output=True) diff --git a/src/claude_swap/settings.py b/src/claude_swap/settings.py index 02b471c..730c9cf 100644 --- a/src/claude_swap/settings.py +++ b/src/claude_swap/settings.py @@ -202,7 +202,7 @@ def default(self): ), SettingSpec( "autoswitch", "threshold7d", "threshold_7d", "optional_float", 1.0, 99.9, - help="Claude weekly (7d) gate; unset uses autoswitch.threshold (Codex Weekly always does)", + help="Claude 7d and Codex Weekly gate; unset uses autoswitch.threshold", ), SettingSpec( "autoswitch", "modelThresholds", "model_thresholds", "model_thresholds", diff --git a/tests/test_codex_autoswitch.py b/tests/test_codex_autoswitch.py index 27ca43a..7530280 100644 --- a/tests/test_codex_autoswitch.py +++ b/tests/test_codex_autoswitch.py @@ -2,6 +2,9 @@ from __future__ import annotations +import json +from dataclasses import replace +from datetime import datetime, timezone from pathlib import Path from claude_swap.autoswitch import TickOutcome @@ -124,3 +127,251 @@ def test_dry_run_reports_a_switch_without_changing_the_active_account(tmp_path): assert switcher.switched_to == [] assert events[-1].kind == "switch" assert events[-1].dry_run + + +def _weekly_account(number, pct, reset, *, active=False, weekly_pct=20): + account = _account(number, pct, active=active) + resets_at = ( + datetime.fromtimestamp(reset, timezone.utc).isoformat() + if reset is not None else None + ) + return replace( + account, + usage=UsageEntry(last_good={ + "five_hour": {"pct": pct}, + "weekly": {"pct": weekly_pct, "resets_at": resets_at}, + }), + ) + + +def test_per_window_gates_trigger_and_keep_absolute_poll_headroom(tmp_path, monkeypatch): + monkeypatch.setattr("claude_swap.codex.is_codex_running", lambda: False) + for window in ("5h", "7d"): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 75 if window == "5h" else 20, 3000, + active=True, weekly_pct=75 if window == "7d" else 20), + _weekly_account("2", 10, 2000, weekly_pct=10), + )) + settings = AutoSwitchSettings(threshold=90, **{ + "threshold_5h" if window == "5h" else "threshold_7d": 75, + }) + events = [] + engine = CodexAutoSwitchEngine( + switcher, settings, events.append, dry_run=True, clock=lambda: 1000 + ) + assert engine.tick() is TickOutcome.SWITCHED + assert events[0].headroom["1"] == 25 + assert events[-1].trigger == "proactive" + + +def test_proactive_candidate_must_clear_each_gate(tmp_path): + for window in ("5h", "7d"): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 95, 3000, active=True), + _weekly_account("2", 71 if window == "5h" else 10, 2000, + weekly_pct=71 if window == "7d" else 10), + )) + settings = AutoSwitchSettings(threshold=90, **{ + "threshold_5h" if window == "5h" else "threshold_7d": 70, + }) + events = [] + engine = CodexAutoSwitchEngine( + switcher, settings, events.append, clock=lambda: 1000 + ) + assert engine.tick() is TickOutcome.BLOCKED + assert not switcher.switched_to + + +def test_consume_first_below_threshold_switches_even_with_less_headroom(tmp_path, monkeypatch): + monkeypatch.setattr("claude_swap.codex.is_codex_running", lambda: False) + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 20, 3000, active=True), + _weekly_account("2", 60, 2000), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.SWITCHED + assert switcher.switched_to == ["2"] + assert events[-1].trigger == "consume-first" + + +def test_consume_first_below_threshold_holds_without_eligible_candidate(tmp_path): + cases = ( + (2000, 3000, 20, 20), # active resets first + (2000, 2000, 20, 20), # strictly sooner is required + (None, 2000, 20, 20), + (900, 2000, 20, 20), # past active reset is unknown + (3000, None, 20, 20), + (3000, 900, 20, 20), # past candidate reset is unknown + (3000, 1000, 20, 20), # reset must be in the future + (3000, 2000, 100, 20), # exhausted candidate + (3000, 2000, 20, 70), # candidate at its Weekly gate + (3000, 2000, 70, 20), # candidate at its 5h gate + ) + for active_reset, candidate_reset, pct, weekly_pct in cases: + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 20, active_reset, active=True), + _weekly_account("2", pct, candidate_reset, weekly_pct=weekly_pct), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first", + threshold_5h=70, threshold_7d=70), + events.append, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.NO_ACTION + assert not switcher.switched_to + assert events[-1].reason == "below-threshold" + + +def test_consume_first_without_candidates_is_not_blocked(tmp_path): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 20, 3000, active=True), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.NO_ACTION + assert events[-1].reason == "below-threshold" + + +def test_consume_first_obeys_cooldown(tmp_path): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 20, 3000, active=True), + _weekly_account("2", 30, 2000), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, clock=lambda: 1000, + ) + engine.state_path.write_text(json.dumps({"lastSwitchAt": 999})) + assert engine.tick() is TickOutcome.NO_ACTION + assert not switcher.switched_to + assert events[-1].reason == "cooldown" + + +def test_consume_first_ranks_future_weekly_resets_before_headroom(tmp_path, monkeypatch): + monkeypatch.setattr("claude_swap.codex.is_codex_running", lambda: False) + for active_pct in (95, 100): + for unknown_reset in (None, 900): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", active_pct, 4000, active=True), + _weekly_account("2", 10, unknown_reset), + _weekly_account("3", 30, 3000), + _weekly_account("4", 60, 2000), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, dry_run=True, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.SWITCHED + assert events[-1].to_ref["number"] == 4 + assert not switcher.switched_to + + +def test_consume_first_dry_run_does_not_write_state(tmp_path, monkeypatch): + monkeypatch.setattr("claude_swap.codex.is_codex_running", lambda: False) + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 20, 3000, active=True), + _weekly_account("2", 30, 2000), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, dry_run=True, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.SWITCHED + assert events[-1].trigger == "consume-first" + assert events[-1].dry_run + assert not switcher.switched_to + assert not engine.state_path.exists() + + +def test_consume_first_rechecks_cooldown_under_lock(tmp_path, monkeypatch): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 20, 3000, active=True), + _weekly_account("2", 30, 2000), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, clock=lambda: 1000, + ) + states = iter(({}, {"lastSwitchAt": 999})) + monkeypatch.setattr(engine, "_read_state", lambda: next(states)) + assert engine.tick() is TickOutcome.NO_ACTION + assert not switcher.switched_to + assert events[-1].reason == "cooldown" + + +def test_consume_first_unknown_resets_fall_back_to_headroom(tmp_path, monkeypatch): + monkeypatch.setattr("claude_swap.codex.is_codex_running", lambda: False) + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 100, 3000, active=True), + _weekly_account("2", 60, None), + _weekly_account("3", 10, 900), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(strategy="consume-first"), + events.append, dry_run=True, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.SWITCHED + assert events[-1].to_ref["number"] == 3 + + +def test_below_threshold_detail_reports_the_tightest_gate(tmp_path): + for strategy in ("best", "consume-first"): + for five_hour, weekly, expected in ( + (70, 80, "Weekly 80% < 85%"), + (86, 80, "5h 86% < 90%"), + (85, 80, "5h 85% < 90%"), # equal slack: higher usage binds + ): + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", five_hour, 3000, active=True, weekly_pct=weekly), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, + AutoSwitchSettings(strategy=strategy, threshold=90, threshold_7d=85), + events.append, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.NO_ACTION + assert events[-1].reason == "below-threshold" + assert events[-1].detail == expected + + +def test_below_threshold_detail_without_overrides_keeps_historical_text(tmp_path): + switcher = FakeCodexSwitcher(tmp_path, (_account("1", 75.4, active=True),)) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(threshold=90), events.append, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.NO_ACTION + assert events[-1].detail == "75% < 90%" + + +def test_best_ranks_candidates_by_distance_to_their_gates(tmp_path, monkeypatch): + monkeypatch.setattr("claude_swap.codex.is_codex_running", lambda: False) + switcher = FakeCodexSwitcher(tmp_path, ( + _weekly_account("1", 95, 3000, active=True), + _weekly_account("2", 10, 2000, weekly_pct=40), + _weekly_account("3", 50, 2000, weekly_pct=0), + )) + events = [] + engine = CodexAutoSwitchEngine( + switcher, AutoSwitchSettings(threshold=90, threshold_7d=50, hysteresis_pct=10), + events.append, dry_run=True, clock=lambda: 1000, + ) + assert engine.tick() is TickOutcome.SWITCHED + assert events[-1].to_ref["number"] == 3 + # Polls continue reporting real quota headroom, not policy-adjusted values. + assert events[0].headroom["2"] == 60 + assert events[0].headroom["3"] == 50