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