From 4e3e46c1afe712726c0602ba64139db5ca0630f7 Mon Sep 17 00:00:00 2001 From: Loi Phan Date: Sat, 11 Jul 2026 21:38:22 +0700 Subject: [PATCH] feat: update the quota tracking for user role --- open-sse/services/usage.js | 2 +- open-sse/services/usage/codex.js | 19 +- src/app/(dashboard)/dashboard/quota/page.js | 18 +- .../ProviderLimits/QuotaProgressBar.js | 11 +- .../usage/components/SystemQuotaOverview.js | 260 ++++++++++++++++++ src/app/api/usage/system-quota/route.js | 221 +++++++++++++++ 6 files changed, 519 insertions(+), 12 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/usage/components/SystemQuotaOverview.js create mode 100644 src/app/api/usage/system-quota/route.js diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 43bfc8ed..dabe53e0 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -32,7 +32,7 @@ const USAGE_HANDLERS = { "gemini-cli": (c) => getGeminiUsage(c.accessToken, c.providerDataWithProjectId, c.proxyOptions), antigravity: (c) => getAntigravityUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions), - codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions), + codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions, c.providerSpecificData), kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), qoder: (c) => getQoderUsage(c.accessToken, c.proxyOptions), qwen: (c) => getQwenUsage(c.accessToken, c.providerSpecificData), diff --git a/open-sse/services/usage/codex.js b/open-sse/services/usage/codex.js index 960af333..eff9f471 100644 --- a/open-sse/services/usage/codex.js +++ b/open-sse/services/usage/codex.js @@ -80,17 +80,26 @@ function getCodexReviewRateLimit(data) { }) || null; } -export async function getCodexUsage(accessToken, proxyOptions = null) { +export async function getCodexUsage(accessToken, proxyOptions = null, providerSpecificData = null) { try { + const accountId = getCodexAccountId(providerSpecificData); + const headers = { + "Authorization": `Bearer ${accessToken}`, + "Accept": "application/json", + "OpenAI-Beta": "codex-1", + "originator": "codex_cli_rs", + }; + if (accountId) headers["ChatGPT-Account-ID"] = accountId; + const response = await proxyAwareFetch(CODEX_CONFIG.usageUrl, { method: "GET", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Accept": "application/json", - }, + headers, }, proxyOptions); if (!response.ok) { + if (response.status === 401 || response.status === 403) { + return { message: `Codex authentication failed (${response.status}). Please re-authorize the connection.` }; + } return { message: `Codex connected. Usage API temporarily unavailable (${response.status}).` }; } diff --git a/src/app/(dashboard)/dashboard/quota/page.js b/src/app/(dashboard)/dashboard/quota/page.js index ee5e7e7b..7b449b90 100644 --- a/src/app/(dashboard)/dashboard/quota/page.js +++ b/src/app/(dashboard)/dashboard/quota/page.js @@ -1,11 +1,25 @@ -import { Suspense } from "react"; +"use client"; + +import { Suspense, useEffect } from "react"; import { CardSkeleton } from "@/shared/components/Loading"; import ProviderLimits from "../usage/components/ProviderLimits"; +import SystemQuotaOverview from "../usage/components/SystemQuotaOverview"; +import useUserStore from "@/store/userStore"; export default function QuotaPage() { + const user = useUserStore((state) => state.user); + const loading = useUserStore((state) => state.loading); + const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser); + + useEffect(() => { + fetchCurrentUser(); + }, [fetchCurrentUser]); + + if (!user || loading) return ; + return ( }> - + {user.role === "admin" ? : } ); } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js index 1a13acd2..0078ccf3 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js @@ -71,6 +71,7 @@ export default function QuotaProgressBar({ unlimited = false, resetTime = null, recurring = true, + showUsageDetails = true, }) { const colors = getColorClasses(percentage); const countdown = formatResetTime(resetTime); @@ -110,11 +111,13 @@ export default function QuotaProgressBar({ {/* Usage details and countdown */}
- - {used.toLocaleString()} / {total.toLocaleString()} requests - + {showUsageDetails && ( + + {used.toLocaleString()} / {total.toLocaleString()} requests + + )} {countdown !== "-" && ( -
+
{resetWord} in {countdown}
diff --git a/src/app/(dashboard)/dashboard/usage/components/SystemQuotaOverview.js b/src/app/(dashboard)/dashboard/usage/components/SystemQuotaOverview.js new file mode 100644 index 00000000..c541ff0c --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/SystemQuotaOverview.js @@ -0,0 +1,260 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { AI_PROVIDERS } from "@/shared/constants/providers"; +import { Button, Card, CardSkeleton } from "@/shared/components"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import { formatResetTime, REFRESH_INTERVAL_MS } from "./ProviderLimits/utils"; + +function getProviderInfo(providerId) { + return AI_PROVIDERS[providerId] || { + name: providerId, + color: "#6b7280", + }; +} + +function formatUpdatedAt(value) { + if (!value) return "Not updated yet"; + + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return "Not updated yet"; + + return `Updated ${date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + })}`; +} + +function formatResetAt(value) { + if (!value) return null; + + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return null; + + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString(); + const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + + if (isToday) return `today at ${time}`; + if (isTomorrow) return `tomorrow at ${time}`; + return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} + +function getQuotaTone(percentage) { + if (percentage > 70) { + return { bar: "bg-green-500", dot: "bg-green-500", text: "text-green-500" }; + } + if (percentage >= 30) { + return { bar: "bg-yellow-500", dot: "bg-yellow-500", text: "text-yellow-500" }; + } + return { bar: "bg-red-500", dot: "bg-red-500", text: "text-red-500" }; +} + +function QuotaListRow({ quota }) { + const tone = getQuotaTone(quota.remainingPercentage); + const resetIn = formatResetTime(quota.resetAt); + const resetAt = formatResetAt(quota.resetAt); + const resetLabel = quota.recurring === false ? "Expires" : "Resets"; + + return ( +
  • +
    + {quota.name} + + +
    +
    +
    +
    + {(resetIn !== "-" || resetAt) && ( +

    + {resetIn !== "-" && {resetLabel} in {resetIn}} + {resetIn !== "-" && resetAt && · } + {resetAt && {resetAt}} +

    + )} +
  • + ); +} + +function ProviderQuotaListItem({ provider }) { + const providerInfo = getProviderInfo(provider.provider); + const providerName = providerInfo.name || provider.provider; + const hasQuotaData = provider.quotas.length > 0; + + return ( +
    +
    +
    +
    + +
    +
    +

    {providerName}

    +

    + {provider.accountCount} {provider.accountCount === 1 ? "account" : "accounts"} connected +

    +
    +
    +
    + + {hasQuotaData ? ( +
      + {provider.quotas.map((quota) => )} +
    + ) : ( +
    + {provider.failedAccountCount > 0 + ? provider.errorMessage || "Quota data is temporarily unavailable for this provider." + : "This provider does not currently report quota data."} +
    + )} + + {provider.failedAccountCount > 0 && hasQuotaData && ( +

    + Some account quota checks could not be completed. +

    + )} +
    + ); +} + +function OverviewSkeleton() { + return ( + + {[1, 2, 3].map((key) => ( +
    + +
    + ))} +
    + ); +} + +export default function SystemQuotaOverview() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const loadQuota = useCallback(async (forceRefresh = false) => { + if (forceRefresh) setRefreshing(true); + setError(null); + + try { + const response = await fetch( + `/api/usage/system-quota${forceRefresh ? "?refresh=true" : ""}`, + { cache: "no-store" }, + ); + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new Error(payload.error || "Failed to load system quota"); + } + + setData(payload); + } catch (loadError) { + setError(loadError.message || "Failed to load system quota"); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + const initialLoadId = window.setTimeout(() => loadQuota(), 0); + const intervalId = window.setInterval(() => loadQuota(true), REFRESH_INTERVAL_MS); + return () => { + window.clearTimeout(initialLoadId); + window.clearInterval(intervalId); + }; + }, [loadQuota]); + + if (loading) return ; + + if (error) { + return ( + + error +
    +

    Unable to load system quota

    +

    {error}

    +
    + +
    + ); + } + + const providers = data?.providers || []; + + return ( +
    +
    +
    +
    + data_usage + System quota +
    +

    Available provider capacity

    +
    +
    + {formatUpdatedAt(data?.updatedAt)} + +
    +
    + + {providers.length === 0 ? ( + + cloud_off +
    +

    No quota-capable providers connected

    +

    + System quota will appear when an administrator connects a provider that supports usage tracking. +

    +
    +
    + ) : ( + + {providers.map((provider, index) => ( +
    0 ? "border-t border-border-subtle" : ""}> + +
    + ))} +
    + )} +
    + ); +} diff --git a/src/app/api/usage/system-quota/route.js b/src/app/api/usage/system-quota/route.js new file mode 100644 index 00000000..c7e9fe41 --- /dev/null +++ b/src/app/api/usage/system-quota/route.js @@ -0,0 +1,221 @@ +// Ensure proxyFetch is loaded to patch globalThis.fetch +import "open-sse/index.js"; + +import { getProviderConnections } from "@/lib/localDb"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; +import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; +import { USAGE_APIKEY_PROVIDERS, USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { refreshAndUpdateCredentials } from "@/app/api/usage/[connectionId]/route"; +import { getUsageForProvider } from "open-sse/services/usage.js"; +import { + getRemainingPercentage, + parseQuotaData, +} from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils"; + +export const dynamic = "force-dynamic"; + +const CACHE_TTL_MS = 60 * 1000; +let cachedSystemQuota = null; + +function isUsageEligible(connection) { + const isApiKey = connection.authType === "apikey" || connection.authType === "api_key"; + return USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) && ( + connection.authType === "oauth" || (isApiKey && USAGE_APIKEY_PROVIDERS.includes(connection.provider)) + ); +} + +function getProxyOptions(proxyConfig) { + return { + connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true, + connectionProxyUrl: proxyConfig.connectionProxyUrl || "", + connectionNoProxy: proxyConfig.connectionNoProxy || "", + vercelRelayUrl: proxyConfig.vercelRelayUrl || "", + strictProxy: false, + }; +} + +function isAuthenticationError(usage) { + const message = usage?.message || usage?.error || ""; + return /expired|authentication|unauthorized|401|403|re-authorize/i.test(message); +} + +function getEarliestFutureReset(currentResetAt, nextResetAt) { + const currentTime = currentResetAt ? new Date(currentResetAt).getTime() : Number.POSITIVE_INFINITY; + const nextTime = nextResetAt ? new Date(nextResetAt).getTime() : Number.POSITIVE_INFINITY; + const now = Date.now(); + + if (nextTime > now && nextTime < currentTime) return nextResetAt; + if (currentTime > now) return currentResetAt; + return nextResetAt || currentResetAt || null; +} + +function sanitizeProviderError(error) { + const message = String(error?.message || error || ""); + if (/re-authorize|refresh credentials|no codex access token|unauthorized|authentication/i.test(message)) { + return "An administrator needs to re-authorize this provider connection."; + } + if (/temporarily unavailable|rate limit|timeout|fetch/i.test(message)) { + return "The provider quota service is temporarily unavailable."; + } + return "Quota data is temporarily unavailable for this provider."; +} + +function buildSystemQuotaResponse(connections, results) { + const providerGroups = new Map(); + + for (const connection of connections) { + if (!providerGroups.has(connection.provider)) { + providerGroups.set(connection.provider, { + provider: connection.provider, + accountCount: 0, + quotaAccountCount: 0, + failedAccountCount: 0, + errorMessage: null, + quotaGroups: new Map(), + }); + } + providerGroups.get(connection.provider).accountCount += 1; + } + + for (const result of results) { + const group = providerGroups.get(result.provider); + if (!group) continue; + + if (result.status !== "fulfilled" || result.value.length === 0) { + group.failedAccountCount += 1; + group.errorMessage ||= result.errorMessage || "Quota data is temporarily unavailable for this provider."; + continue; + } + + group.quotaAccountCount += 1; + for (const quota of result.value) { + if (!quota?.name) continue; + + if (!group.quotaGroups.has(quota.name)) { + group.quotaGroups.set(quota.name, { + name: quota.name, + percentageTotal: 0, + accountCount: 0, + resetAt: null, + recurring: quota.recurring !== false, + }); + } + + const quotaGroup = group.quotaGroups.get(quota.name); + quotaGroup.percentageTotal += getRemainingPercentage(quota); + quotaGroup.accountCount += 1; + quotaGroup.resetAt = getEarliestFutureReset(quotaGroup.resetAt, quota.resetAt); + quotaGroup.recurring = quotaGroup.recurring && quota.recurring !== false; + } + } + + const providers = Array.from(providerGroups.values()) + .map((group) => ({ + provider: group.provider, + accountCount: group.accountCount, + quotaAccountCount: group.quotaAccountCount, + failedAccountCount: group.failedAccountCount, + errorMessage: group.errorMessage, + quotas: Array.from(group.quotaGroups.values()).map((quota) => ({ + name: quota.name, + remainingPercentage: Math.round(quota.percentageTotal / quota.accountCount), + accountCount: quota.accountCount, + resetAt: quota.resetAt, + recurring: quota.recurring, + })), + })) + .sort((a, b) => a.provider.localeCompare(b.provider)); + + return { + providers, + updatedAt: new Date().toISOString(), + }; +} + +async function fetchConnectionQuota(connection) { + const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData); + const proxyOptions = getProxyOptions(proxyConfig); + let usableConnection = connection; + + if (connection.authType === "oauth") { + const refreshed = await refreshAndUpdateCredentials(connection, false, proxyOptions); + usableConnection = refreshed.connection; + } + + let usage = await getUsageForProvider(usableConnection, proxyOptions); + + // Match the existing per-connection quota endpoint: a provider can reject an + // otherwise unexpired OAuth access token, in which case a forced refresh and + // a single retry is required before declaring the aggregate unavailable. + if (connection.authType === "oauth" && isAuthenticationError(usage) && connection.refreshToken) { + const refreshed = await refreshAndUpdateCredentials(usableConnection, true, proxyOptions); + usableConnection = refreshed.connection; + usage = await getUsageForProvider(usableConnection, proxyOptions); + } + + if (usage?.message || usage?.error) { + throw new Error(usage.message || usage.error); + } + + return parseQuotaData(connection.provider, usage).filter((quota) => ( + Number.isFinite(getRemainingPercentage(quota)) + )); +} + +/** + * GET /api/usage/system-quota + * + * Returns provider-level average quota availability across every eligible system + * connection. It deliberately excludes all connection, credential, and owner data. + */ +export async function GET(request) { + try { + await requireUsageDashboardUser(); + + const { searchParams } = new URL(request.url); + const forceRefresh = searchParams.get("refresh") === "true"; + const cacheIsFresh = cachedSystemQuota && Date.now() - cachedSystemQuota.cachedAt < CACHE_TTL_MS; + + if (!forceRefresh && cacheIsFresh) { + return Response.json({ ...cachedSystemQuota.data, cached: true }); + } + + const connections = (await getProviderConnections({})).filter(isUsageEligible); + const results = await Promise.allSettled( + connections.map(async (connection) => ({ + provider: connection.provider, + quotas: await fetchConnectionQuota(connection), + })), + ); + + const normalizedResults = results.map((result, index) => { + if (result.status === "fulfilled") { + return { + status: result.status, + provider: result.value.provider, + value: result.value.quotas, + }; + } + + console.warn(`[System quota] ${connections[index].provider}: ${result.reason?.message || "quota fetch failed"}`); + return { + status: result.status, + provider: connections[index].provider, + value: [], + errorMessage: sanitizeProviderError(result.reason), + }; + }); + + const data = buildSystemQuotaResponse(connections, normalizedResults); + cachedSystemQuota = { data, cachedAt: Date.now() }; + + return Response.json({ ...data, cached: false }); + } catch (error) { + if (error?.message === "Unauthorized") { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + console.error("[System quota] Failed to build aggregate quota:", error); + return Response.json({ error: "Failed to load system quota" }, { status: 500 }); + } +}