Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/auto-switching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <num|email>`; `ccswap enable <num|email>` 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 <num|email>` 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
Expand Down
2 changes: 1 addition & 1 deletion docs/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
94 changes: 79 additions & 15 deletions src/claude_swap/codex_autoswitch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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."""

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/claude_swap/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading