From 0e30d15c0112e68c3f003506c95353f7b6254114 Mon Sep 17 00:00:00 2001 From: rexname Date: Sun, 15 Mar 2026 01:21:35 +0700 Subject: [PATCH 1/2] feat(codex): add account-level 5h/weekly quota policy and auto-rotation - add quota window status helper for Codex session (5h) and weekly windows - enforce policy-based account filtering when enabled windows reach threshold - return all-rate-limited metadata when no Codex account is eligible - add per-account dashboard toggles for 5h and weekly policy controls - merge codexLimitPolicy safely on provider updates to preserve partial settings - document purpose and usage scenarios in README (EN + ID + i18n note) --- README.md | 17 +++ docs/i18n/README.md | 9 ++ docs/i18n/id/README.md | 17 +++ .../dashboard/providers/[id]/page.tsx | 103 ++++++++++++++++ src/app/api/providers/[id]/route.ts | 39 ++++++- src/domain/quotaCache.ts | 52 +++++++++ src/sse/services/auth.ts | 110 +++++++++++++++++- 7 files changed, 342 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b48c28b98f..69fe17227e 100644 --- a/README.md +++ b/README.md @@ -1292,6 +1292,23 @@ Models: cx/gpt-5.1-codex-max ``` +#### Codex Account Limit Management (5h + Weekly) + +Each Codex account now has policy toggles in `Dashboard -> Providers`: + +- `5h` (ON/OFF): enforce the 5-hour window threshold policy. +- `Weekly` (ON/OFF): enforce the weekly window threshold policy. +- Threshold behavior: when an enabled window reaches >=90% usage, that account is skipped. +- Rotation behavior: OmniRoute routes to the next eligible Codex account automatically. +- Reset behavior: when the provider `resetAt` time passes, the account becomes eligible again automatically. + +Scenarios: + +- `5h ON` + `Weekly ON`: account is skipped when either window reaches threshold. +- `5h OFF` + `Weekly ON`: only weekly usage can block the account. +- `5h ON` + `Weekly OFF`: only 5-hour usage can block the account. +- `resetAt` passed: account re-enters rotation automatically (no manual re-enable). + ### Gemini CLI (FREE 180K/month!) ```bash diff --git a/docs/i18n/README.md b/docs/i18n/README.md index b3407af2a9..bd0cf76ce8 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -9,4 +9,13 @@ This directory contains machine-assisted translations based on the English docs. - **TROUBLESHOOTING.md**: 🇺🇸 [English](../TROUBLESHOOTING.md) | 🇧🇷 [Português (Brasil)](./pt-BR/TROUBLESHOOTING.md) | 🇪🇸 [Español](./es/TROUBLESHOOTING.md) | 🇫🇷 [Français](./fr/TROUBLESHOOTING.md) | 🇮🇹 [Italiano](./it/TROUBLESHOOTING.md) | 🇷🇺 [Русский](./ru/TROUBLESHOOTING.md) | 🇨🇳 [中文 (简体)](./zh-CN/TROUBLESHOOTING.md) | 🇩🇪 [Deutsch](./de/TROUBLESHOOTING.md) | 🇮🇳 [हिन्दी](./in/TROUBLESHOOTING.md) | 🇹🇭 [ไทย](./th/TROUBLESHOOTING.md) | 🇺🇦 [Українська](./uk-UA/TROUBLESHOOTING.md) | 🇸🇦 [العربية](./ar/TROUBLESHOOTING.md) | 🇯🇵 [日本語](./ja/TROUBLESHOOTING.md) | 🇻🇳 [Tiếng Việt](./vi/TROUBLESHOOTING.md) | 🇧🇬 [Български](./bg/TROUBLESHOOTING.md) | 🇩🇰 [Dansk](./da/TROUBLESHOOTING.md) | 🇫🇮 [Suomi](./fi/TROUBLESHOOTING.md) | 🇮🇱 [עברית](./he/TROUBLESHOOTING.md) | 🇭🇺 [Magyar](./hu/TROUBLESHOOTING.md) | 🇮🇩 [Bahasa Indonesia](./id/TROUBLESHOOTING.md) | 🇰🇷 [한국어](./ko/TROUBLESHOOTING.md) | 🇲🇾 [Bahasa Melayu](./ms/TROUBLESHOOTING.md) | 🇳🇱 [Nederlands](./nl/TROUBLESHOOTING.md) | 🇳🇴 [Norsk](./no/TROUBLESHOOTING.md) | 🇵🇹 [Português (Portugal)](./pt/TROUBLESHOOTING.md) | 🇷🇴 [Română](./ro/TROUBLESHOOTING.md) | 🇵🇱 [Polski](./pl/TROUBLESHOOTING.md) | 🇸🇰 [Slovenčina](./sk/TROUBLESHOOTING.md) | 🇸🇪 [Svenska](./sv/TROUBLESHOOTING.md) | 🇵🇭 [Filipino](./phi/TROUBLESHOOTING.md) - **USER_GUIDE.md**: 🇺🇸 [English](../USER_GUIDE.md) | 🇧🇷 [Português (Brasil)](./pt-BR/USER_GUIDE.md) | 🇪🇸 [Español](./es/USER_GUIDE.md) | 🇫🇷 [Français](./fr/USER_GUIDE.md) | 🇮🇹 [Italiano](./it/USER_GUIDE.md) | 🇷🇺 [Русский](./ru/USER_GUIDE.md) | 🇨🇳 [中文 (简体)](./zh-CN/USER_GUIDE.md) | 🇩🇪 [Deutsch](./de/USER_GUIDE.md) | 🇮🇳 [हिन्दी](./in/USER_GUIDE.md) | 🇹🇭 [ไทย](./th/USER_GUIDE.md) | 🇺🇦 [Українська](./uk-UA/USER_GUIDE.md) | 🇸🇦 [العربية](./ar/USER_GUIDE.md) | 🇯🇵 [日本語](./ja/USER_GUIDE.md) | 🇻🇳 [Tiếng Việt](./vi/USER_GUIDE.md) | 🇧🇬 [Български](./bg/USER_GUIDE.md) | 🇩🇰 [Dansk](./da/USER_GUIDE.md) | 🇫🇮 [Suomi](./fi/USER_GUIDE.md) | 🇮🇱 [עברית](./he/USER_GUIDE.md) | 🇭🇺 [Magyar](./hu/USER_GUIDE.md) | 🇮🇩 [Bahasa Indonesia](./id/USER_GUIDE.md) | 🇰🇷 [한국어](./ko/USER_GUIDE.md) | 🇲🇾 [Bahasa Melayu](./ms/USER_GUIDE.md) | 🇳🇱 [Nederlands](./nl/USER_GUIDE.md) | 🇳🇴 [Norsk](./no/USER_GUIDE.md) | 🇵🇹 [Português (Portugal)](./pt/USER_GUIDE.md) | 🇷🇴 [Română](./ro/USER_GUIDE.md) | 🇵🇱 [Polski](./pl/USER_GUIDE.md) | 🇸🇰 [Slovenčina](./sk/USER_GUIDE.md) | 🇸🇪 [Svenska](./sv/USER_GUIDE.md) | 🇵🇭 [Filipino](./phi/USER_GUIDE.md) +## Recent note: Codex account limit policy + +Documentation now includes Codex account-level quota policy behavior: + +- Per-account toggles: `5h` and `Weekly` (ON/OFF). +- Threshold policy: enabled window reaching >=90% marks account as ineligible for selection. +- Auto-rotation: traffic moves to the next eligible Codex account. +- Auto-reuse: account becomes eligible again after provider `resetAt` passes. + Generated on 2026-02-26. diff --git a/docs/i18n/id/README.md b/docs/i18n/id/README.md index f1f2b4542c..ead4c2a4ae 100644 --- a/docs/i18n/id/README.md +++ b/docs/i18n/id/README.md @@ -1059,6 +1059,23 @@ Models: cx/gpt-5.1-codex-max ``` +#### Manajemen Limit Akun Codex (5h + Mingguan) + +Setiap akun Codex sekarang punya toggle kebijakan di `Dashboard -> Providers`: + +- `5h` (ON/OFF): menerapkan kebijakan ambang untuk jendela 5 jam. +- `Weekly` (ON/OFF): menerapkan kebijakan ambang untuk jendela mingguan. +- Perilaku ambang: saat jendela yang aktif mencapai >=90% penggunaan, akun tersebut di-skip. +- Perilaku rotasi: OmniRoute otomatis merutekan ke akun Codex berikutnya yang masih eligible. +- Perilaku reset: saat waktu `resetAt` provider sudah lewat, akun otomatis bisa dipakai lagi. + +Skenario: + +- `5h ON` + `Weekly ON`: akun di-skip jika salah satu jendela mencapai ambang. +- `5h OFF` + `Weekly ON`: hanya penggunaan mingguan yang bisa memblokir akun. +- `5h ON` + `Weekly OFF`: hanya penggunaan 5 jam yang bisa memblokir akun. +- `resetAt` sudah lewat: akun otomatis masuk rotasi lagi (tanpa enable manual). + ### Gemini CLI (GRATIS 180K/bulan!) ```bash diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index a96cf62888..a25668823d 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -311,6 +311,58 @@ export default function ProviderDetailPage() { } }; + const handleToggleCodexLimit = async (connectionId, field, enabled) => { + try { + const target = connections.find((connection) => connection.id === connectionId); + if (!target) return; + + const providerSpecificData = + target.providerSpecificData && typeof target.providerSpecificData === "object" + ? target.providerSpecificData + : {}; + const existingPolicy = + providerSpecificData.codexLimitPolicy && + typeof providerSpecificData.codexLimitPolicy === "object" + ? providerSpecificData.codexLimitPolicy + : {}; + + const nextPolicy = { + use5h: typeof existingPolicy.use5h === "boolean" ? existingPolicy.use5h : true, + useWeekly: typeof existingPolicy.useWeekly === "boolean" ? existingPolicy.useWeekly : true, + [field]: enabled, + }; + + const res = await fetch(`/api/providers/${connectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerSpecificData: { + ...providerSpecificData, + codexLimitPolicy: nextPolicy, + }, + }), + }); + + if (res.ok) { + setConnections((prev) => + prev.map((connection) => + connection.id === connectionId + ? { + ...connection, + providerSpecificData: { + ...(connection.providerSpecificData || {}), + codexLimitPolicy: nextPolicy, + }, + } + : connection + ) + ); + } + } catch (error) { + console.error("Error toggling Codex quota policy:", error); + } + }; + const handleRetestConnection = async (connectionId) => { if (!connectionId || retestingId) return; setRetestingId(connectionId); @@ -941,6 +993,11 @@ export default function ProviderDetailPage() { onMoveDown={() => handleSwapPriority(conn, connections[index + 1])} onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)} onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} + isCodex={providerId === "codex"} + onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled)} + onToggleCodexWeekly={(enabled) => + handleToggleCodexLimit(conn.id, "useWeekly", enabled) + } onRetest={() => handleRetestConnection(conn.id)} isRetesting={retestingId === conn.id} onEdit={() => { @@ -2175,12 +2232,15 @@ function getStatusPresentation(connection, effectiveStatus, isCooldown, t) { function ConnectionRow({ connection, isOAuth, + isCodex, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onToggleRateLimit, + onToggleCodex5h, + onToggleCodexWeekly, onRetest, isRetesting, onEdit, @@ -2242,6 +2302,16 @@ function ConnectionRow({ const statusPresentation = getStatusPresentation(connection, effectiveStatus, isCooldown, t); const rateLimitEnabled = !!connection.rateLimitProtection; + const codexPolicy = + connection.providerSpecificData && + typeof connection.providerSpecificData === "object" && + connection.providerSpecificData.codexLimitPolicy && + typeof connection.providerSpecificData.codexLimitPolicy === "object" + ? connection.providerSpecificData.codexLimitPolicy + : {}; + const codex5hEnabled = typeof codexPolicy.use5h === "boolean" ? codexPolicy.use5h : true; + const codexWeeklyEnabled = + typeof codexPolicy.useWeekly === "boolean" ? codexPolicy.useWeekly : true; return (
shield {rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")} + {isCodex && ( + <> + | + + + + )} {hasProxy && (() => { const colorClass = @@ -2451,14 +2550,18 @@ ConnectionRow.propTypes = { lastErrorSource: PropTypes.string, errorCode: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), globalPriority: PropTypes.number, + providerSpecificData: PropTypes.object, }).isRequired, isOAuth: PropTypes.bool.isRequired, + isCodex: PropTypes.bool, isFirst: PropTypes.bool.isRequired, isLast: PropTypes.bool.isRequired, onMoveUp: PropTypes.func.isRequired, onMoveDown: PropTypes.func.isRequired, onToggleActive: PropTypes.func.isRequired, onToggleRateLimit: PropTypes.func.isRequired, + onToggleCodex5h: PropTypes.func, + onToggleCodexWeekly: PropTypes.func, onRetest: PropTypes.func.isRequired, isRetesting: PropTypes.bool, onEdit: PropTypes.func.isRequired, diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 8e92b8dbaf..6d91e3f3f1 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -10,6 +10,30 @@ import { syncToCloud } from "@/lib/cloudSync"; import { updateProviderConnectionSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +function normalizeCodexLimitPolicy( + incoming: unknown, + existing: unknown +): { use5h: boolean; useWeekly: boolean } { + const incomingRecord = + incoming && typeof incoming === "object" && !Array.isArray(incoming) + ? (incoming as Record) + : {}; + const existingRecord = + existing && typeof existing === "object" && !Array.isArray(existing) + ? (existing as Record) + : {}; + + const existingUse5h = typeof existingRecord.use5h === "boolean" ? existingRecord.use5h : true; + const existingUseWeekly = + typeof existingRecord.useWeekly === "boolean" ? existingRecord.useWeekly : true; + + return { + use5h: typeof incomingRecord.use5h === "boolean" ? incomingRecord.use5h : existingUse5h, + useWeekly: + typeof incomingRecord.useWeekly === "boolean" ? incomingRecord.useWeekly : existingUseWeekly, + }; +} + // GET /api/providers/[id] - Get single connection export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { try { @@ -105,7 +129,20 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: existing.providerSpecificData && typeof existing.providerSpecificData === "object" ? existing.providerSpecificData : {}; - updateData.providerSpecificData = { ...existingPsd, ...incomingPsd }; + const mergedPsd = { ...existingPsd, ...incomingPsd }; + + // Deep-merge and normalize Codex limit policy defaults. + if (existing.provider === "codex") { + const incomingRecord = incomingPsd as Record; + if ("codexLimitPolicy" in incomingRecord || "codexLimitPolicy" in existingPsd) { + mergedPsd.codexLimitPolicy = normalizeCodexLimitPolicy( + incomingRecord.codexLimitPolicy, + (existingPsd as Record).codexLimitPolicy + ); + } + } + + updateData.providerSpecificData = mergedPsd; } const updated = await updateProviderConnection(id, updateData); diff --git a/src/domain/quotaCache.ts b/src/domain/quotaCache.ts index 4111e202de..30df9c8852 100644 --- a/src/domain/quotaCache.ts +++ b/src/domain/quotaCache.ts @@ -35,6 +35,13 @@ interface QuotaCacheEntry { windowDurationMs?: number | null; // T08: optional rolling window duration } +interface QuotaWindowStatus { + remainingPercentage: number; + usedPercentage: number; + resetAt: string | null; + reachedThreshold: boolean; +} + // ─── Constants ────────────────────────────────────────────────────────────── const ACTIVE_TTL_MS = 5 * 60 * 1000; // 5 minutes for active accounts @@ -89,6 +96,11 @@ function parseDate(value: string): number | null { return Number.isNaN(ms) ? null : ms; } +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, value)); +} + function earliestResetAt(quotas: Record): string | null { let earliest: string | null = null; let earliestMs = Infinity; @@ -175,6 +187,46 @@ export function isAccountQuotaExhausted(connectionId: string): boolean { return true; } +/** + * Return quota window status for a connection (e.g., session/weekly). + * Returns null when no cache or no window data is available. + */ +export function getQuotaWindowStatus( + connectionId: string, + windowName: string, + thresholdPercent = 90 +): QuotaWindowStatus | null { + const entry = cache.get(connectionId); + if (!entry) return null; + + const now = Date.now(); + const advanced = advancedWindowResetAt(entry, now); + if (advanced) { + entry.exhausted = false; + } + + const window = entry.quotas[windowName]; + if (!window) return null; + + const remainingPercentage = clampPercent(window.remainingPercentage); + const usedPercentage = clampPercent(100 - remainingPercentage); + + let resetAt = window.resetAt || null; + if (resetAt) { + const resetMs = parseDate(resetAt); + if (resetMs !== null && resetMs <= now) { + resetAt = null; + } + } + + return { + remainingPercentage, + usedPercentage, + resetAt, + reachedThreshold: usedPercentage >= thresholdPercent, + }; +} + /** * Mark an account as quota-exhausted from a 429 response (no quota data available). * Uses 5-minute fixed TTL since we don't know the actual resetAt. diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index badcf451c3..c431c2952e 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -4,7 +4,7 @@ import { updateProviderConnection, getSettings, } from "@/lib/localDb"; -import { isAccountQuotaExhausted } from "@/domain/quotaCache"; +import { getQuotaWindowStatus, isAccountQuotaExhausted } from "@/domain/quotaCache"; import { isAccountUnavailable, getUnavailableUntil, @@ -79,6 +79,28 @@ function toProviderConnection(value: unknown): ProviderConnectionView { }; } +function toBooleanOrDefault(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function getCodexLimitPolicy(providerSpecificData: JsonRecord): { + use5h: boolean; + useWeekly: boolean; +} { + const policy = asRecord(providerSpecificData.codexLimitPolicy); + return { + use5h: toBooleanOrDefault(policy.use5h, true), + useWeekly: toBooleanOrDefault(policy.useWeekly, true), + }; +} + +function parseFutureDateMs(value: string | null): number | null { + if (!value) return null; + const ms = new Date(value).getTime(); + if (!Number.isFinite(ms) || ms <= Date.now()) return null; + return ms; +} + // Mutex to prevent race conditions during account selection let selectionMutex = Promise.resolve(); @@ -198,11 +220,91 @@ export async function getProviderCredentials( return null; } + let policyEligibleConnections = availableConnections; + if (provider === "codex") { + const blockedByPolicy: Array<{ + id: string; + reasons: string[]; + resetAt: string | null; + }> = []; + + policyEligibleConnections = availableConnections.filter((connection) => { + const policy = getCodexLimitPolicy(connection.providerSpecificData); + const sessionStatus = policy.use5h + ? getQuotaWindowStatus(connection.id, "session", 90) + : null; + const weeklyStatus = policy.useWeekly + ? getQuotaWindowStatus(connection.id, "weekly", 90) + : null; + + const reasons: string[] = []; + const resetCandidates: Array = []; + + if (policy.use5h && sessionStatus?.reachedThreshold) { + reasons.push(`5h usage ${Math.round(sessionStatus.usedPercentage)}%`); + resetCandidates.push(sessionStatus.resetAt); + } + + if (policy.useWeekly && weeklyStatus?.reachedThreshold) { + reasons.push(`weekly usage ${Math.round(weeklyStatus.usedPercentage)}%`); + resetCandidates.push(weeklyStatus.resetAt); + } + + if (reasons.length > 0) { + const nextResetAt = + resetCandidates + .map((candidate) => ({ + raw: candidate, + ms: parseFutureDateMs(candidate), + })) + .filter((entry) => entry.ms !== null) + .sort((a, b) => (a.ms as number) - (b.ms as number))[0]?.raw || null; + + blockedByPolicy.push({ + id: connection.id, + reasons, + resetAt: nextResetAt, + }); + return false; + } + + return true; + }); + + if (blockedByPolicy.length > 0) { + log.info( + "AUTH", + `${provider} | quota policy filtered ${blockedByPolicy.length} account(s): ${blockedByPolicy + .map((entry) => `${entry.id.slice(0, 8)}(${entry.reasons.join(", ")})`) + .join("; ")}` + ); + } + + if (policyEligibleConnections.length === 0 && availableConnections.length > 0) { + const earliestResetMs = blockedByPolicy + .map((entry) => parseFutureDateMs(entry.resetAt)) + .filter((ms): ms is number => ms !== null) + .sort((a, b) => a - b)[0]; + + const retryAfter = earliestResetMs + ? new Date(earliestResetMs).toISOString() + : new Date(Date.now() + 5 * 60 * 1000).toISOString(); + + return { + allRateLimited: true, + retryAfter, + retryAfterHuman: formatRetryAfter(retryAfter), + lastError: "All Codex accounts reached configured quota threshold", + lastErrorCode: 429, + }; + } + } + // Quota-aware: prioritize accounts with available quota - const withQuota = availableConnections.filter((c) => !isAccountQuotaExhausted(c.id)); - const exhaustedQuota = availableConnections.filter((c) => isAccountQuotaExhausted(c.id)); + const withQuota = policyEligibleConnections.filter((c) => !isAccountQuotaExhausted(c.id)); + const exhaustedQuota = policyEligibleConnections.filter((c) => isAccountQuotaExhausted(c.id)); const orderedConnections = - withQuota.length > 0 ? [...withQuota, ...exhaustedQuota] : availableConnections; + withQuota.length > 0 ? [...withQuota, ...exhaustedQuota] : policyEligibleConnections; if (exhaustedQuota.length > 0) { log.debug( From 1555883633427d804bcc42ba867d246274058a3e Mon Sep 17 00:00:00 2001 From: rexname Date: Sun, 15 Mar 2026 01:35:19 +0700 Subject: [PATCH 2/2] fix(codex): address PR review feedback for quota policy flow - add user-facing success/error notifications for Codex limit toggle API calls - deduplicate Codex policy default normalization in providers page - make getQuotaWindowStatus side-effect free (no cache mutation in getter) - avoid stale threshold blocking after resetAt has passed - extract named Codex quota threshold constant - extract helper for earliest future reset date selection --- .../dashboard/providers/[id]/page.tsx | 56 ++++++++++++------- src/domain/quotaCache.ts | 9 ++- src/sse/services/auth.ts | 35 +++++++----- 3 files changed, 61 insertions(+), 39 deletions(-) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index a25668823d..80a7b27273 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -32,6 +32,17 @@ import { import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } { + const record = + policy && typeof policy === "object" && !Array.isArray(policy) + ? (policy as Record) + : {}; + return { + use5h: typeof record.use5h === "boolean" ? record.use5h : true, + useWeekly: typeof record.useWeekly === "boolean" ? record.useWeekly : true, + }; +} + export default function ProviderDetailPage() { const params = useParams(); const router = useRouter(); @@ -49,6 +60,7 @@ export default function ProviderDetailPage() { const [headerImgError, setHeaderImgError] = useState(false); const { copied, copy } = useCopyToClipboard(); const t = useTranslations("providers"); + const notify = useNotificationStore(); const hasAutoOpened = useRef(false); const userDismissed = useRef(false); const [proxyTarget, setProxyTarget] = useState(null); @@ -327,8 +339,7 @@ export default function ProviderDetailPage() { : {}; const nextPolicy = { - use5h: typeof existingPolicy.use5h === "boolean" ? existingPolicy.use5h : true, - useWeekly: typeof existingPolicy.useWeekly === "boolean" ? existingPolicy.useWeekly : true, + ...normalizeCodexLimitPolicy(existingPolicy), [field]: enabled, }; @@ -343,23 +354,29 @@ export default function ProviderDetailPage() { }), }); - if (res.ok) { - setConnections((prev) => - prev.map((connection) => - connection.id === connectionId - ? { - ...connection, - providerSpecificData: { - ...(connection.providerSpecificData || {}), - codexLimitPolicy: nextPolicy, - }, - } - : connection - ) - ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + notify.error(data.error || "Failed to update Codex limit policy"); + return; } + + setConnections((prev) => + prev.map((connection) => + connection.id === connectionId + ? { + ...connection, + providerSpecificData: { + ...(connection.providerSpecificData || {}), + codexLimitPolicy: nextPolicy, + }, + } + : connection + ) + ); + notify.success("Codex limit policy updated"); } catch (error) { console.error("Error toggling Codex quota policy:", error); + notify.error("Failed to update Codex limit policy"); } }; @@ -383,7 +400,6 @@ export default function ProviderDetailPage() { // T12: Manual token refresh const [refreshingId, setRefreshingId] = useState(null); - const notify = useNotificationStore(); const handleRefreshToken = async (connectionId: string) => { if (refreshingId) return; setRefreshingId(connectionId); @@ -2309,9 +2325,9 @@ function ConnectionRow({ typeof connection.providerSpecificData.codexLimitPolicy === "object" ? connection.providerSpecificData.codexLimitPolicy : {}; - const codex5hEnabled = typeof codexPolicy.use5h === "boolean" ? codexPolicy.use5h : true; - const codexWeeklyEnabled = - typeof codexPolicy.useWeekly === "boolean" ? codexPolicy.useWeekly : true; + const normalizedCodexPolicy = normalizeCodexLimitPolicy(codexPolicy); + const codex5hEnabled = normalizedCodexPolicy.use5h; + const codexWeeklyEnabled = normalizedCodexPolicy.useWeekly; return (
= thresholdPercent, + // If reset time has already passed, avoid stale cached percentages blocking selection. + reachedThreshold: windowExpired ? false : usedPercentage >= thresholdPercent, }; } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index c431c2952e..d1d834714d 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -38,6 +38,8 @@ interface ProviderConnectionView { backoffLevel: number; } +const CODEX_QUOTA_THRESHOLD_PERCENT = 90; + function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } @@ -101,6 +103,18 @@ function parseFutureDateMs(value: string | null): number | null { return ms; } +function getEarliestFutureDate(candidates: Array): string | null { + return ( + candidates + .map((candidate) => ({ + raw: candidate, + ms: parseFutureDateMs(candidate), + })) + .filter((entry) => entry.ms !== null) + .sort((a, b) => (a.ms as number) - (b.ms as number))[0]?.raw || null + ); +} + // Mutex to prevent race conditions during account selection let selectionMutex = Promise.resolve(); @@ -231,10 +245,10 @@ export async function getProviderCredentials( policyEligibleConnections = availableConnections.filter((connection) => { const policy = getCodexLimitPolicy(connection.providerSpecificData); const sessionStatus = policy.use5h - ? getQuotaWindowStatus(connection.id, "session", 90) + ? getQuotaWindowStatus(connection.id, "session", CODEX_QUOTA_THRESHOLD_PERCENT) : null; const weeklyStatus = policy.useWeekly - ? getQuotaWindowStatus(connection.id, "weekly", 90) + ? getQuotaWindowStatus(connection.id, "weekly", CODEX_QUOTA_THRESHOLD_PERCENT) : null; const reasons: string[] = []; @@ -251,14 +265,7 @@ export async function getProviderCredentials( } if (reasons.length > 0) { - const nextResetAt = - resetCandidates - .map((candidate) => ({ - raw: candidate, - ms: parseFutureDateMs(candidate), - })) - .filter((entry) => entry.ms !== null) - .sort((a, b) => (a.ms as number) - (b.ms as number))[0]?.raw || null; + const nextResetAt = getEarliestFutureDate(resetCandidates); blockedByPolicy.push({ id: connection.id, @@ -281,10 +288,10 @@ export async function getProviderCredentials( } if (policyEligibleConnections.length === 0 && availableConnections.length > 0) { - const earliestResetMs = blockedByPolicy - .map((entry) => parseFutureDateMs(entry.resetAt)) - .filter((ms): ms is number => ms !== null) - .sort((a, b) => a - b)[0]; + const earliestResetAt = getEarliestFutureDate( + blockedByPolicy.map((entry) => entry.resetAt) + ); + const earliestResetMs = parseFutureDateMs(earliestResetAt); const retryAfter = earliestResetMs ? new Date(earliestResetMs).toISOString()