From 87fad5d1719f687ec7ddc1c8159a698de4c4b368 Mon Sep 17 00:00:00 2001 From: Apostol Apostolov Date: Wed, 27 May 2026 23:05:18 +0300 Subject: [PATCH] [codex] home: restore settings-driven home layout and quota auto-refresh (#2800) Integrated into release/v3.8.6 --- .../(dashboard)/dashboard/HomePageClient.tsx | 16 +- .../settings/components/AppearanceTab.tsx | 158 +++++++++ .../usage/components/ProviderLimits/index.tsx | 323 +++++++++++------- .../(dashboard)/home/ProviderQuotaWidget.tsx | 100 +++++- src/i18n/messages/en.json | 7 + src/lib/db/settings.ts | 2 + src/shared/validation/settingsSchemas.ts | 2 + src/sse/handlers/chat.ts | 14 +- src/sse/services/auth.ts | 12 +- src/types/settings.ts | 2 + tests/unit/provider-limits-ui.test.ts | 10 + 11 files changed, 487 insertions(+), 159 deletions(-) diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index e7401d55c9..739cd9a1ea 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -14,7 +14,7 @@ import { copyToClipboard } from "@/shared/utils/clipboard"; import { useIsElectron, useOpenExternal } from "@/shared/hooks/useElectron"; const ProviderTopology = dynamic(() => import("../home/ProviderTopology"), { ssr: false }); -const ProviderLimits = dynamic(() => import("./usage/components/ProviderLimits"), { ssr: false }); +const ProviderQuotaWidget = dynamic(() => import("../home/ProviderQuotaWidget"), { ssr: false }); import type { NewsAnnouncement } from "@/shared/utils/releaseNotes"; type UpdateStep = { @@ -196,6 +196,8 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { const [pinProviderQuotaToHome, setPinProviderQuotaToHome] = useState(false); const [showQuickStartOnHome, setShowQuickStartOnHome] = useState(true); // default on const [showProviderTopologyOnHome, setShowProviderTopologyOnHome] = useState(true); // default on + const [autoRefreshProviderQuota, setAutoRefreshProviderQuota] = useState(false); + const [autoRefreshProviderQuotaInterval, setAutoRefreshProviderQuotaInterval] = useState(180); useEffect(() => { // Fetch the pin settings (lightweight) @@ -212,6 +214,12 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { if (typeof data.showProviderTopologyOnHome === "boolean") { setShowProviderTopologyOnHome(data.showProviderTopologyOnHome); } + if (typeof data.autoRefreshProviderQuota === "boolean") { + setAutoRefreshProviderQuota(data.autoRefreshProviderQuota); + } + if (typeof data.autoRefreshProviderQuotaInterval === "number") { + setAutoRefreshProviderQuotaInterval(data.autoRefreshProviderQuotaInterval); + } } }) .catch(() => { @@ -1066,7 +1074,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { {/* Pinned Provider Quota Limits (compact, no filters) */} {pinProviderQuotaToHome && ( }> - + )} diff --git a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx index d25d3375fb..5c41be21b8 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx @@ -13,6 +13,7 @@ import { normalizeComboConfigMode, type ComboConfigMode, } from "@/shared/constants/comboConfigMode"; +import { PIN_PROVIDER_QUOTA_TO_HOME_KEY } from "@/shared/constants/homeWidgets"; export default function AppearanceTab() { const { theme, setTheme, isDark } = useTheme(); @@ -34,6 +35,13 @@ export default function AppearanceTab() { const isValidHex = /^#([0-9a-fA-F]{6})$/.test( customThemeColor.startsWith("#") ? customThemeColor : `#${customThemeColor}` ); + const pinProviderQuotaToHome = settings.pinProviderQuotaToHome === true; + const showQuickStartOnHome = settings.showQuickStartOnHome !== false; + const showProviderTopologyOnHome = settings.showProviderTopologyOnHome !== false; + const autoRefreshProviderQuota = settings.autoRefreshProviderQuota === true; + const autoRefreshProviderQuotaInterval = Number.isFinite(settings.autoRefreshProviderQuotaInterval) + ? Number(settings.autoRefreshProviderQuotaInterval) + : 180; const comboConfigMode = normalizeComboConfigMode(settings[COMBO_CONFIG_MODE_SETTING_KEY]); const showCloudflaredTunnel = settings.hideEndpointCloudflaredTunnel !== true; const showTailscaleFunnel = settings.hideEndpointTailscaleFunnel !== true; @@ -123,6 +131,10 @@ export default function AppearanceTab() { }, ]; + const quotaRefreshInterval = Number.isFinite(autoRefreshProviderQuotaInterval) + ? Math.min(3600, Math.max(10, Math.floor(autoRefreshProviderQuotaInterval))) + : 180; + return (
@@ -170,6 +182,82 @@ export default function AppearanceTab() {
+
+
+

+ {getSettingsLabel("homePinProviderQuotaToHome", "Pin Information to Home Page")} +

+

+ Choose which sections to pin to the top of the Home page. +

+
+ +
+
+
+
+

+ {getSettingsLabel("homeProviderQuotaLimits", "Provider Quota Limits")} +

+

+ {getSettingsLabel( + "homeProviderQuotaLimitsDesc", + "Pin the Provider Quota status container (with Refresh All button) to the top of the Home page." + )} +

+
+ { + await updateSetting(PIN_PROVIDER_QUOTA_TO_HOME_KEY, checked); + }} + disabled={loading} + /> +
+ +
+
+

{getSettingsLabel("homeQuickStart", "Quick Start")}

+

+ {getSettingsLabel( + "homeQuickStartDesc", + "Show the Quick Start panel on the Home page." + )} +

+
+ { + await updateSetting("showQuickStartOnHome", checked); + }} + disabled={loading} + /> +
+ +
+
+

+ {getSettingsLabel("homeProviderTopology", "Provider Topology")} +

+

+ {getSettingsLabel( + "homeProviderTopologyDesc", + "Show the Provider Topology on the Home page." + )} +

+
+ { + await updateSetting("showProviderTopologyOnHome", checked); + }} + disabled={loading} + /> +
+
+
+
+

{t("themeAccent")}

{t("themeAccentDesc")}

@@ -348,6 +436,76 @@ export default function AppearanceTab() {
+
+
+

+ {getSettingsLabel("providerQuotaAutoRefresh", "Provider Quota auto refresh")} +

+

+ {getSettingsLabel( + "providerQuotaAutoRefreshDesc", + "Refresh the Provider Limits view automatically while it stays open." + )} +

+
+ +
+
+
+

+ {getSettingsLabel("providerQuotaAutoRefreshToggle", "Automatic refresh")} +

+

+ {getSettingsLabel( + "providerQuotaAutoRefreshToggleDesc", + "Refresh the quota view every few minutes while the page is visible." + )} +

+
+ { + if (checked && !settings.autoRefreshProviderQuotaInterval) { + await updateSetting("autoRefreshProviderQuotaInterval", 180); + } + await updateSetting("autoRefreshProviderQuota", checked); + }} + disabled={loading} + /> +
+ +
+
+

+ {getSettingsLabel("providerQuotaAutoRefreshInterval", "Refresh interval")} +

+

+ {getSettingsLabel( + "providerQuotaAutoRefreshIntervalDesc", + "How often the quota view should refresh, in seconds." + )} +

+
+
+ { + const next = Math.min(3600, Math.max(10, Number(e.target.value) || 180)); + await updateSetting("autoRefreshProviderQuotaInterval", next); + }} + disabled={loading || !autoRefreshProviderQuota} + className="h-10 w-28 px-3 rounded-lg bg-surface border border-border text-sm text-text-main focus:outline-none focus:border-primary disabled:opacity-50" + /> + seconds +
+
+
+
+
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index e99c8f6f3a..bc653f92f5 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -192,7 +192,22 @@ function aggregateWorst(statuses: StatusKey[]): "critical" | "alert" | "ok" | "e return worst; } -export default function ProviderLimits() { +function formatAutoRefreshCountdown(ms: number): string { + const totalSeconds = Math.max(0, Math.ceil(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +} + +interface ProviderLimitsProps { + showFilters?: boolean; + autoRefreshInterval?: number; +} + +export default function ProviderLimits({ + showFilters = true, + autoRefreshInterval = 0, +}: ProviderLimitsProps) { const t = useTranslations("usage"); const tr = useCallback( (key: string, fallback: string, values?: UsageTranslationValues) => @@ -228,6 +243,9 @@ export default function ProviderLimits() { const lastFetchTimeRef = useRef>({}); const staleProbeRef = useRef>({}); + const lastRefreshAllAtRef = useRef(Date.now()); + const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0; + const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now()); const [cutoffModalConn, setCutoffModalConn] = useState(null); const [cutoffModalWindows, setCutoffModalWindows] = useState([]); const [providerWindowDefaults, setProviderWindowDefaults] = useState< @@ -401,6 +419,9 @@ export default function ProviderLimits() { const refreshAll = useCallback(async () => { if (refreshingAllRef.current) return; refreshingAllRef.current = true; + const now = Date.now(); + lastRefreshAllAtRef.current = now; + setAutoRefreshClock(now); setRefreshingAll(true); try { const response = await fetch("/api/usage/provider-limits", { method: "POST" }); @@ -422,6 +443,33 @@ export default function ProviderLimits() { } }, [applyCachedQuotaState, fetchConnections]); + useEffect(() => { + if (autoRefreshIntervalMs <= 0) return; + + const tick = () => setAutoRefreshClock(Date.now()); + tick(); + + const timer = window.setInterval(tick, 1000); + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") tick(); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + window.clearInterval(timer); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [autoRefreshIntervalMs]); + + useEffect(() => { + if (autoRefreshIntervalMs <= 0) return; + if (document.visibilityState !== "visible") return; + if (refreshingAllRef.current) return; + if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) { + void refreshAll(); + } + }, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]); + useEffect(() => { const init = async () => { setInitialLoading(true); @@ -702,148 +750,159 @@ export default function ProviderLimits() { onClick={refreshAll} disabled={refreshingAll} className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" + title={autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll")} > - refresh + {autoRefreshIntervalMs > 0 ? "schedule" : "refresh"} - {t("refreshAll")} + {refreshingAll + ? tr("refreshing", "Refreshing") + : autoRefreshIntervalMs > 0 + ? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown( + Math.max(0, autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)) + )}` + : t("refreshAll")}
- {/* Summary stats — clickable status filter */} -
- {(["all", "critical", "alert", "ok"] as StatusKey[]).map((key) => { - const tone = STATUS_TONE[key]; - const labelMap: Record = { - all: tr("statTotal", "Total"), - critical: tr("statCritical", "Critical"), - alert: tr("statAlert", "Alert"), - ok: tr("statHealthy", "Healthy"), - }; - const active = statusFilter === key; - const count = statusCounts[key] || 0; - return ( - - ); - })} -
+ {showFilters && ( + <> + {/* Summary stats — clickable status filter */} +
+ {(["all", "critical", "alert", "ok"] as StatusKey[]).map((key) => { + const tone = STATUS_TONE[key]; + const labelMap: Record = { + all: tr("statTotal", "Total"), + critical: tr("statCritical", "Critical"), + alert: tr("statAlert", "Alert"), + ok: tr("statHealthy", "Healthy"), + }; + const active = statusFilter === key; + const count = statusCounts[key] || 0; + return ( + + ); + })} +
- {/* Purchase Type filter */} -
- - {tr("filterPurchaseTypeLabel", "Type")} - - {PURCHASE_TYPES.map((type) => { - const count = purchaseTypeCounts[type.key] || 0; - if (type.key !== "all" && count === 0) return null; - const active = purchaseTypeFilter === type.key; - return ( - - ); - })} -
+ {/* Purchase Type filter */} +
+ + {tr("filterPurchaseTypeLabel", "Type")} + + {PURCHASE_TYPES.map((type) => { + const count = purchaseTypeCounts[type.key] || 0; + if (type.key !== "all" && count === 0) return null; + const active = purchaseTypeFilter === type.key; + return ( + + ); + })} +
- {/* Tier filter */} -
- - {tr("filterTierLabel", "Tier")} - - {TIER_FILTERS.map((tier) => { - if (tier.key !== "all" && !tierCounts[tier.key]) return null; - const active = tierFilter === tier.key; - return ( - - ); - })} -
+ {/* Tier filter */} +
+ + {tr("filterTierLabel", "Tier")} + + {TIER_FILTERS.map((tier) => { + if (tier.key !== "all" && !tierCounts[tier.key]) return null; + const active = tierFilter === tier.key; + return ( + + ); + })} +
- {/* Env filter — only renders when at least one connection has a tag */} - {envTags.length > 0 && ( -
- - {tr("filterEnvLabel", "Env")} - - {(["all", ...envTags] as string[]).map((tag) => { - const count = envCounts[tag] || 0; - const active = envFilter === tag; - const label = tag === "all" ? tr("filterEnvAll", "All") : tag; - return ( - - ); - })} -
+ {/* Env filter — only renders when at least one connection has a tag */} + {envTags.length > 0 && ( +
+ + {tr("filterEnvLabel", "Env")} + + {(["all", ...envTags] as string[]).map((tag) => { + const count = envCounts[tag] || 0; + const active = envFilter === tag; + const label = tag === "all" ? tr("filterEnvAll", "All") : tag; + return ( + + ); + })} +
+ )} + )} {/* Provider groups */} diff --git a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx index 221c54dd5c..eb267fafdb 100644 --- a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx +++ b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import Card from "@/shared/components/Card"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { translateUsageOrFallback } from "../dashboard/usage/components/ProviderLimits/i18nFallback"; type Connection = { id: string; @@ -16,9 +17,23 @@ type Connection = { type QuotaData = Record; -export default function ProviderQuotaWidget() { +interface ProviderQuotaWidgetProps { + autoRefreshInterval?: number; +} + +function formatAutoRefreshCountdown(ms: number): string { + const totalSeconds = Math.max(0, Math.ceil(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +} + +export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: ProviderQuotaWidgetProps) { const t = useTranslations("usage"); - const tc = useTranslations("common"); + const tr = useCallback( + (key: string, fallback: string) => translateUsageOrFallback(t, key, fallback), + [t] + ); const [connections, setConnections] = useState([]); const [quotaData, setQuotaData] = useState({}); @@ -26,6 +41,9 @@ export default function ProviderQuotaWidget() { const [refreshingAll, setRefreshingAll] = useState(false); const refreshingAllRef = useRef(false); + const lastRefreshAllAtRef = useRef(Date.now()); + const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0; + const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now()); const fetchConnections = useCallback(async () => { try { @@ -69,9 +87,30 @@ export default function ProviderQuotaWidget() { loadData(); }, [loadData]); + useEffect(() => { + if (autoRefreshIntervalMs <= 0) return; + + const tick = () => setAutoRefreshClock(Date.now()); + tick(); + + const timer = window.setInterval(tick, 1000); + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") tick(); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + window.clearInterval(timer); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [autoRefreshIntervalMs]); + const refreshAll = useCallback(async () => { if (refreshingAllRef.current) return; refreshingAllRef.current = true; + const now = Date.now(); + lastRefreshAllAtRef.current = now; + setAutoRefreshClock(now); setRefreshingAll(true); try { @@ -98,6 +137,15 @@ export default function ProviderQuotaWidget() { } }, [fetchConnections, fetchCached]); + useEffect(() => { + if (autoRefreshIntervalMs <= 0) return; + if (document.visibilityState !== "visible") return; + if (refreshingAllRef.current) return; + if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) { + void refreshAll(); + } + }, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]); + // Simple summary: group by provider for display const providerGroups = connections.reduce>((acc, conn) => { if (!acc[conn.provider]) acc[conn.provider] = []; @@ -116,9 +164,9 @@ export default function ProviderQuotaWidget() { account_balance
-

{t("providerQuota") || "Provider Quota"}

+

{tr("providerQuota", "Provider Quota")}

- {t("providerQuotaHomeHint") || "Live status across connected accounts"} + {tr("providerQuotaHomeHint", "Live status across connected accounts")}

@@ -127,14 +175,22 @@ export default function ProviderQuotaWidget() { onClick={refreshAll} disabled={refreshingAll || loading} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-bg-subtle text-xs font-medium text-text-main disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface transition-colors" - title={t("refreshAll") || "Refresh All"} + title={autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : tr("refreshAll", "Refresh All")} > - refresh + {autoRefreshIntervalMs > 0 ? "schedule" : "refresh"} + + + {refreshingAll + ? tr("refreshing", "Refreshing") + : autoRefreshIntervalMs > 0 + ? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown( + Math.max(0, autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)) + )}` + : tr("refreshAll", "Refresh All")} - {t("refreshAll") || "Refresh All"}
@@ -143,13 +199,16 @@ export default function ProviderQuotaWidget() { {loading ? (
progress_activity - Loading quota information... + {tr("loadingQuotas", "Loading...")}
) : providerEntries.length === 0 ? (
- No quota-supported providers connected yet. + {tr("noProviders", "No Providers Connected")}
- Add accounts on the Providers page to see quota status here. + {tr( + "connectProvidersForQuota", + "Connect to providers with OAuth to track your API quota limits and usage." + )}
) : ( @@ -169,19 +228,23 @@ export default function ProviderQuotaWidget() { {provider.charAt(0).toUpperCase() + provider.slice(1)} - - {conns.length} account{conns.length > 1 ? "s" : ""} + + {conns.length} {hasQuota ? ( -
- {Object.keys(cache.quotas).length} quota window(s) tracked +
+ {Object.keys(cache.quotas).length}
) : ( -
- No quota data yet — click Refresh All -
+ )} {/* Future: embed small QuotaProgressBar for the primary window here */} @@ -193,7 +256,8 @@ export default function ProviderQuotaWidget() {
- View full Provider Quota page → + {tr("viewDetails", "View details")} +
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 6f9ffaa7e5..5115037d36 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4355,6 +4355,13 @@ "modelsDevInfoOrder": "User Override → models.dev → LiteLLM → Hardcoded Default", "systemTheme": "System Theme", "debugToggle": "Enable Debug Mode", + "homePinProviderQuotaToHome": "Pin Information to Home Page", + "homeProviderQuotaLimits": "Provider Quota Limits", + "homeProviderQuotaLimitsDesc": "Pin the Provider Quota status container (with Refresh All button) to the top of the Home page.", + "homeQuickStart": "Quick Start", + "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", + "homeProviderTopology": "Provider Topology", + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", "sidebarVisibilityToggle": "Show Sidebar Items", "enableCache": "Enable Cache", "cacheTTL": "Cache TTL", diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 3501421373..c9cbe957f7 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -99,6 +99,8 @@ export async function getSettings() { hideEndpointCloudflaredTunnel: false, hideEndpointTailscaleFunnel: false, hideEndpointNgrokTunnel: false, + autoRefreshProviderQuota: false, + autoRefreshProviderQuotaInterval: 180, comboConfigMode: "guided", codexServiceTier: { enabled: false }, claudeFastMode: { enabled: false, supportedModels: ["claude-opus-4-7", "claude-opus-4-6"] }, diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 30b12ed9bd..e278ab8173 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -34,6 +34,8 @@ export const updateSettingsSchema = z.object({ hideEndpointCloudflaredTunnel: z.boolean().optional(), hideEndpointTailscaleFunnel: z.boolean().optional(), hideEndpointNgrokTunnel: z.boolean().optional(), + autoRefreshProviderQuota: z.boolean().optional(), + autoRefreshProviderQuotaInterval: z.number().int().min(10).max(3600).optional(), debugMode: z.boolean().optional(), hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(), sidebarSectionOrder: z diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 7c2988cfaa..51fae0b6ed 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -1148,6 +1148,10 @@ async function handleSingleModelChat( // Daily quota lockout overrides subsequent rate_limited lockout, ensuring lockout until tomorrow 0:00 let dailyQuotaExhausted = false; const errorStr = String(result.error || ""); + const failureKind = + result.status === 429 + ? classify429FromError({ status: result.status, message: errorStr }) + : undefined; if (result.status === 429 && isDailyQuotaExhausted(errorStr)) { // Parse which model is quota-limited const match = errorStr.match(/today's quota for model ([^,]+)/); @@ -1182,10 +1186,6 @@ async function handleSingleModelChat( // quotaCache as exhausted for 5 minutes while usage quota may still be available. if (!dailyQuotaExhausted) { const passthroughModels = credentials.providerSpecificData?.passthroughModels; - const failureKind = - result.status === 429 - ? classify429FromError({ status: result.status, message: errorStr }) - : undefined; if ( result.status === 429 && shouldMarkAccountExhaustedFrom429(provider, model, passthroughModels, failureKind) @@ -1212,7 +1212,11 @@ async function handleSingleModelChat( result.error, provider, model, - providerProfile + providerProfile, + { + persistUnavailableState: + !(isCombo && result.status === 429 && (failureKind === "rate_limit" || failureKind === "transient")), + } ); if (shouldFallback) { diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 79edfc1169..24cb3015c6 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -1558,7 +1558,10 @@ export async function markAccountUnavailable( errorText: string, provider: string | null = null, model: string | null = null, - providerProfile = null + providerProfile = null, + options: { + persistUnavailableState?: boolean; + } = {} ) { const currentMutex = markMutexes.get(connectionId) || Promise.resolve(); let resolveMutex: (() => void) | undefined; @@ -1782,8 +1785,13 @@ export async function markAccountUnavailable( lastErrorAt: new Date().toISOString(), backoffLevel: newBackoffLevel ?? backoffLevel, }; + const persistUnavailableState = options.persistUnavailableState !== false; - if (cooldownMs > 0) { + if (!persistUnavailableState) { + await updateProviderConnection(connectionId, { + ...baseUpdate, + }); + } else if (cooldownMs > 0) { await updateProviderConnection(connectionId, { ...baseUpdate, rateLimitedUntil: getUnavailableUntil(cooldownMs), diff --git a/src/types/settings.ts b/src/types/settings.ts index 69f6ea1d70..52a46b16aa 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -29,6 +29,8 @@ export interface Settings { hideEndpointCloudflaredTunnel?: boolean; hideEndpointTailscaleFunnel?: boolean; hideEndpointNgrokTunnel?: boolean; + autoRefreshProviderQuota?: boolean; + autoRefreshProviderQuotaInterval?: number; pinProviderQuotaToHome?: boolean; showQuickStartOnHome?: boolean; showProviderTopologyOnHome?: boolean; diff --git a/tests/unit/provider-limits-ui.test.ts b/tests/unit/provider-limits-ui.test.ts index 7867a63d79..29d2111fc9 100644 --- a/tests/unit/provider-limits-ui.test.ts +++ b/tests/unit/provider-limits-ui.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; const providerLimitUtils = await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx"); const providerConstants = await import("../../src/shared/constants/providers.ts"); +const settingsSchemas = await import("../../src/shared/validation/settingsSchemas.ts"); test("provider plan fallbacks normalize to Unknown instead of repeating provider labels", () => { const tier = providerLimitUtils.normalizePlanTier("Claude Code"); @@ -223,3 +224,12 @@ test("usage namespace includes Provider Limits UI translation keys", () => { assert.ok(!usage[key].startsWith("__MISSING__:"), `usage.${key} should not be a placeholder`); } }); + +test("provider quota auto-refresh settings are accepted by the settings schema", () => { + const result = settingsSchemas.updateSettingsSchema.safeParse({ + autoRefreshProviderQuota: true, + autoRefreshProviderQuotaInterval: 180, + }); + + assert.equal(result.success, true); +});