diff --git a/gui/src/codex-quota-utils.ts b/gui/src/codex-quota-utils.ts
index 1770dc01d3..7749a502bc 100644
--- a/gui/src/codex-quota-utils.ts
+++ b/gui/src/codex-quota-utils.ts
@@ -53,3 +53,39 @@ export function normalizeQuotaForPlan(quota: AccountQuota | null, plan: string |
updatedAt: normalized.updatedAt,
};
}
+
+/**
+ * Compute the governing Codex usage score matching the server's auto-switch threshold evaluation.
+ *
+ * Evaluates governing quota windows based on the account's plan:
+ * - For 30-day only plans (e.g. Free/Go), only the monthly window governs.
+ * - For standard plans, weekly and monthly windows govern.
+ * - A known five-hour / short window refines a known governing long-window score.
+ * - If no long window has been observed, an active terminal short burst (at 100%) acts as exhausted (100).
+ * - Unknown or unprimed quota returns `null` so callers do not spuriously trigger threshold actions.
+ */
+export function computeCodexUsageScore(
+ quota: AccountQuota | null | undefined,
+ plan?: string | null,
+ now: number = Date.now(),
+): number | null {
+ if (!quota) return null;
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
+ const shortPercent = finite(quota.fiveHourPercent)
+ ? quota.fiveHourPercent
+ : (finite(quota.shortPercent) ? quota.shortPercent : undefined);
+ const longWindows = isThirtyDayOnlyPlan(plan)
+ ? [quota.monthlyPercent]
+ : [quota.weeklyPercent, quota.monthlyPercent];
+ const knownLong = longWindows.filter(finite);
+ if (knownLong.length === 0) {
+ const shortReset = quota.fiveHourResetAt ?? quota.shortResetAt;
+ const isExhausted = finite(shortPercent) && shortPercent >= 100 && (
+ (typeof shortReset === "number" && shortReset > now) ||
+ (typeof quota.updatedAt === "number" && now - quota.updatedAt < 5 * 60 * 60 * 1000)
+ );
+ return isExhausted ? 100 : null;
+ }
+ const values = finite(shortPercent) ? [...knownLong, shortPercent] : knownLong;
+ return values.length ? Math.max(...values) : null;
+}
diff --git a/gui/src/components/AccountPoolStrategyControls.tsx b/gui/src/components/AccountPoolStrategyControls.tsx
index 2a813bcc4e..fe04a6d20c 100644
--- a/gui/src/components/AccountPoolStrategyControls.tsx
+++ b/gui/src/components/AccountPoolStrategyControls.tsx
@@ -24,6 +24,7 @@ const STRATEGY_HINT_KEYS = {
export interface AccountPoolStrategyControlsProps {
strategy: AccountPoolStrategy;
codex?: boolean;
+ threshold?: number;
stickyDraft: string;
disabled?: boolean;
strategySelectId?: string;
@@ -45,6 +46,7 @@ export interface AccountPoolStrategyControlsProps {
export default function AccountPoolStrategyControls({
strategy,
codex = false,
+ threshold,
stickyDraft,
disabled = false,
strategySelectId = "account-pool-strategy",
@@ -59,6 +61,23 @@ export default function AccountPoolStrategyControls({
label: t(STRATEGY_LABEL_KEYS[value]),
}));
+ const thresholdSummary = (() => {
+ if (threshold === undefined) return null;
+ if (strategy === "round-robin") {
+ return t("accountPool.thresholdNotUsed");
+ }
+ if (threshold > 0) {
+ if (strategy === "fill-first") {
+ return t("accountPool.drainAtThreshold", { threshold: String(threshold) });
+ }
+ if (strategy === "reset-first") {
+ return t("accountPool.resetBelowThreshold", { threshold: String(threshold) });
+ }
+ return t("accountPool.switchAtThreshold", { threshold: String(threshold) });
+ }
+ return t("accountPool.proactiveSwitchingOff");
+ })();
+
return (
{/*
@@ -86,6 +105,11 @@ export default function AccountPoolStrategyControls({
label={t("accountPool.strategy")}
onChange={(next) => onStrategyChange(next as AccountPoolStrategy)}
/>
+ {thresholdSummary && (
+
+ {thresholdSummary}
+
+ )}
{strategy === "round-robin" && (
diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx
index c7e006be6a..e438b19ce6 100644
--- a/gui/src/components/CodexAccountPool.tsx
+++ b/gui/src/components/CodexAccountPool.tsx
@@ -546,6 +546,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
subscribeLoadObserver={controller.subscribeLoadObserver}
readLastActive={controller.readLastActive}
onStrategyResolved={setPoolStrategy}
+ threshold={autoSwitch.threshold}
/>
setConfirm(null)}
onConfirm={() => { void setActive(confirm.id === "__main__" ? "__main__" : confirm.id); }}
/>
diff --git a/gui/src/components/CodexPoolStrategySetting.tsx b/gui/src/components/CodexPoolStrategySetting.tsx
index 6e44d793ce..56b3ffc31d 100644
--- a/gui/src/components/CodexPoolStrategySetting.tsx
+++ b/gui/src/components/CodexPoolStrategySetting.tsx
@@ -13,16 +13,22 @@ import {
import AccountPoolStrategyControls from "./AccountPoolStrategyControls";
import type { CodexAccountLoadObserver } from "../hooks/useCodexAccountPool";
+/**
+ * Extract normalized strategy, sticky limit, and optional autoSwitchThreshold from an active-response payload.
+ */
function strategyFieldsFromActive(value: unknown): {
strategy: AccountPoolStrategy;
stickyLimit: number;
+ threshold?: number;
} | null {
if (!value || typeof value !== "object") return null;
const row = value as Record;
- if (!("accountPoolStrategy" in row) && !("accountPoolStickyLimit" in row)) return null;
+ if (!("accountPoolStrategy" in row) && !("accountPoolStickyLimit" in row) && !("autoSwitchThreshold" in row)) return null;
+ const threshold = typeof row.autoSwitchThreshold === "number" ? row.autoSwitchThreshold : undefined;
return {
strategy: normalizeAccountPoolStrategy(row.accountPoolStrategy),
stickyLimit: normalizeAccountPoolStickyLimit(row.accountPoolStickyLimit),
+ threshold,
};
}
@@ -36,17 +42,20 @@ export default function CodexPoolStrategySetting({
subscribeLoadObserver,
readLastActive,
onStrategyResolved,
+ threshold: propThreshold,
}: {
apiBase: string;
subscribeLoadObserver?: (observer: CodexAccountLoadObserver) => () => void;
readLastActive?: () => unknown;
onStrategyResolved?: (strategy: AccountPoolStrategy) => void;
+ threshold?: number;
}) {
const t = useT();
// Seed defaults immediately — never gate the control chrome on a network round-trip.
const [strategy, setStrategy] = useState(DEFAULT_ACCOUNT_POOL_STRATEGY);
const [stickyLimit, setStickyLimit] = useState(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT);
const [stickyDraft, setStickyDraft] = useState(String(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT));
+ const [serverThreshold, setServerThreshold] = useState(undefined);
const [hydrated, setHydrated] = useState(false);
const hydratedRef = useRef(false);
const [saving, setSaving] = useState(false);
@@ -60,9 +69,13 @@ export default function CodexPoolStrategySetting({
const applyServer = useCallback((json: {
accountPoolStrategy?: unknown;
accountPoolStickyLimit?: unknown;
+ autoSwitchThreshold?: unknown;
}) => {
const nextStrategy = normalizeAccountPoolStrategy(json.accountPoolStrategy);
const nextSticky = normalizeAccountPoolStickyLimit(json.accountPoolStickyLimit);
+ if (typeof json.autoSwitchThreshold === "number") {
+ setServerThreshold(json.autoSwitchThreshold);
+ }
setStrategy(nextStrategy);
onStrategyResolved?.(nextStrategy);
setStickyLimit(nextSticky);
@@ -79,6 +92,7 @@ export default function CodexPoolStrategySetting({
applyServer({
accountPoolStrategy: fields.strategy,
accountPoolStickyLimit: fields.stickyLimit,
+ autoSwitchThreshold: fields.threshold,
});
}, [applyServer]);
@@ -89,6 +103,7 @@ export default function CodexPoolStrategySetting({
const payload = await res.json() as {
accountPoolStrategy?: unknown;
accountPoolStickyLimit?: unknown;
+ autoSwitchThreshold?: unknown;
};
// A save started while this GET was in flight — retry once after it settles.
if (savingRef.current) {
@@ -215,6 +230,7 @@ export default function CodexPoolStrategySetting({
void;
onConfirm: () => void;
}) {
@@ -39,6 +46,9 @@ export function CodexAccountSwitchModal({
onCancel();
}, [onCancel]);
+ const usageScore = computeCodexUsageScore(confirm.quota, confirm.plan);
+ const exceedsThreshold = threshold !== undefined && threshold > 0 && usageScore !== null && usageScore >= threshold;
+
return (