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 ( {t("codexAuth.cacheWarning")} )} + {exceedsThreshold && ( +
+ {t("codexAuth.switchExceedsThresholdWarning", { threshold })} +
+ )}