diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index bc03e281d9..940f4611f2 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -650,6 +650,25 @@ export default function ProviderLimits({ quotaData, ]); + // Auto-fetch LIVE quota on open for visible connections that have no cached + // quota yet (e.g. a Codex account whose access_token expired — its per-connection + // live fetch refreshes the token serialized/cascade-safe and surfaces real quota). + // Scoped to what's on screen and to the entries actually missing data (the ones + // that already have cache render instantly and are not re-fetched), and runs once + // per page open so it never loops on the quotaData it writes. + const autoLiveFetchedRef = useRef(false); + useEffect(() => { + if (initialLoading || autoLiveFetchedRef.current || visibleConnections.length === 0) return; + autoLiveFetchedRef.current = true; + for (const conn of visibleConnections) { + const cached = quotaData[conn.id]; + const hasQuota = Array.isArray(cached?.quotas) && cached.quotas.length > 0; + if (!hasQuota) { + void fetchQuota(conn.id, conn.provider, { force: true }).catch(() => {}); + } + } + }, [initialLoading, visibleConnections, quotaData, fetchQuota]); + const handleSetPurchaseFilter = useCallback((value: PurchaseTypeKey) => { setPurchaseTypeFilter(value); try { diff --git a/src/app/api/usage/[connectionId]/route.ts b/src/app/api/usage/[connectionId]/route.ts index 67d241d15c..55d4bf6133 100644 --- a/src/app/api/usage/[connectionId]/route.ts +++ b/src/app/api/usage/[connectionId]/route.ts @@ -3,6 +3,13 @@ import { fetchAndPersistProviderLimits } from "@/lib/usage/providerLimits"; /** * GET /api/usage/[connectionId] - Get live usage data for a specific connection * and persist the refreshed Provider Limits cache. + * + * This is the on-demand, per-connection path (the dashboard quota page fetches + * only the connections it shows through here, not all of them at once). It opts + * into refreshing rotating-refresh providers (Codex/OpenAI) so an account with an + * expired access_token still surfaces live quota — made cascade-safe by + * `serializeRefresh` (one token mint at a time per Auth0 group). The bulk + * scheduler keeps the #3019 behaviour of never refreshing rotating providers. */ export async function GET( _request: Request, @@ -10,7 +17,9 @@ export async function GET( ) { try { const { connectionId } = await params; - const { usage } = await fetchAndPersistProviderLimits(connectionId, "manual"); + const { usage } = await fetchAndPersistProviderLimits(connectionId, "manual", { + allowRotatingRefresh: true, + }); return Response.json(usage); } catch (error) { const status = diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 61d3fd7096..2b1fbe57e0 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -18,7 +18,7 @@ import { getMachineId } from "@/shared/utils/machine"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; import { getExecutor } from "@omniroute/open-sse/executors/index.ts"; import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts"; -import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer.ts"; +import { rotationGroupFor, serializeRefresh } from "@omniroute/open-sse/services/refreshSerializer.ts"; import { extractCodeAssistOnboardTierId, extractCodeAssistSubscriptionTier, @@ -111,15 +111,32 @@ async function syncToCloudIfEnabled() { } } -export async function refreshAndUpdateCredentials(connection: ProviderConnectionLike) { - // Rotating-refresh providers (Codex/OpenAI share one Auth0 client_id, etc.) - // mint a single-use refresh_token on every refresh. The quota-sync path runs - // many connections concurrently; refreshing sibling accounts in parallel makes - // Auth0 revoke the whole token family (openai/codex#9648) and kills every - // account but the last. Never proactively refresh them here — reuse the current - // access_token for the quota fetch and let the reactive, serialized 401 path - // handle genuine expiry during real requests. - if (rotationGroupFor(connection.provider) !== null) { +/** + * Whether the quota path may refresh this provider's token. Exported for testing. + * + * Rotating-refresh providers (Codex/OpenAI share one Auth0 client_id, etc.) mint a + * single-use refresh_token on every refresh. The BULK quota-sync path runs many + * connections concurrently; refreshing sibling accounts in parallel makes Auth0 + * revoke the whole token family (openai/codex#9648) and kills every account but + * the last (#3019). So the bulk path never refreshes rotating providers + * (`allowRotatingRefresh` falsy). The on-demand, per-connection path opts in and + * is made safe by `serializeRefresh` (one token mint at a time per rotation group, + * so even N concurrent per-account requests can never refresh siblings in + * parallel). Non-rotating providers are always eligible. + */ +export function shouldAttemptRotatingRefresh( + provider: string, + allowRotatingRefresh: boolean | undefined +): boolean { + if (rotationGroupFor(provider) === null) return true; + return allowRotatingRefresh === true; +} + +export async function refreshAndUpdateCredentials( + connection: ProviderConnectionLike, + opts: { allowRotatingRefresh?: boolean } = {} +) { + if (!shouldAttemptRotatingRefresh(connection.provider, opts.allowRotatingRefresh)) { return { connection, refreshed: false }; } const executor = getExecutor(connection.provider); @@ -137,7 +154,11 @@ export async function refreshAndUpdateCredentials(connection: ProviderConnection return { connection, refreshed: false }; } - const refreshResult = await executor.refreshCredentials(credentials, console); + // Serialize the actual token mint per rotation group so two sibling accounts + // never hit Auth0 concurrently (passthrough for non-rotating providers). + const refreshResult = await serializeRefresh(connection.provider, () => + executor.refreshCredentials(credentials, console) + ); if (!refreshResult) { if (connection.provider === "github" && connection.accessToken) { @@ -389,7 +410,7 @@ export async function fetchLiveProviderLimits(connectionId: string): Promise<{ async function fetchLiveProviderLimitsWithOptions( connectionId: string, - options: { forceRefresh?: boolean } = {} + options: { forceRefresh?: boolean; allowRotatingRefresh?: boolean } = {} ): Promise<{ connection: ProviderConnectionLike; usage: JsonRecord; @@ -424,7 +445,9 @@ async function fetchLiveProviderLimitsWithOptions( let conn = connection as ProviderConnectionLike; let wasRefreshed = false; - const result = await refreshAndUpdateCredentials(conn); + const result = await refreshAndUpdateCredentials(conn, { + allowRotatingRefresh: options.allowRotatingRefresh, + }); conn = result.connection; wasRefreshed = result.refreshed; @@ -505,7 +528,8 @@ async function fetchLiveProviderLimitsWithOptions( export async function fetchAndPersistProviderLimits( connectionId: string, - source: SyncSource = "manual" + source: SyncSource = "manual", + opts: { allowRotatingRefresh?: boolean } = {} ): Promise<{ connection: ProviderConnectionLike; usage: JsonRecord; @@ -513,6 +537,7 @@ export async function fetchAndPersistProviderLimits( }> { const { connection, usage } = await fetchLiveProviderLimitsWithOptions(connectionId, { forceRefresh: source === "manual", + allowRotatingRefresh: opts.allowRotatingRefresh, }); const newCache = toProviderLimitsCacheEntry(usage, source); diff --git a/tests/unit/provider-limits-rotating-expired-guard.test.ts b/tests/unit/provider-limits-rotating-expired-guard.test.ts index ecbe258be6..2168bd4710 100644 --- a/tests/unit/provider-limits-rotating-expired-guard.test.ts +++ b/tests/unit/provider-limits-rotating-expired-guard.test.ts @@ -9,7 +9,9 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-rotating- process.env.DATA_DIR = TEST_DATA_DIR; process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "rotating-expired-guard-secret"; -const { quotaPathShouldMarkExpired } = await import("../../src/lib/usage/providerLimits.ts"); +const { quotaPathShouldMarkExpired, shouldAttemptRotatingRefresh } = await import( + "../../src/lib/usage/providerLimits.ts" +); test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); @@ -47,3 +49,26 @@ test("an already-expired connection is left untouched (no redundant write)", () assert.equal(quotaPathShouldMarkExpired("github", "token expired", "expired"), false); assert.equal(quotaPathShouldMarkExpired("codex", "token expired", "expired"), false); }); + +// Option 1: the on-demand per-connection path may refresh a rotating provider's +// expired token (cascade-safe via serializeRefresh), so its live quota shows; +// the bulk scheduler (allowRotatingRefresh falsy) must keep #3019 and never do it. +test("bulk path never refreshes rotating providers (preserves #3019)", () => { + for (const provider of ["codex", "openai", "claude", "kiro", "qwen", "gitlab-duo"]) { + assert.equal(shouldAttemptRotatingRefresh(provider, undefined), false, `${provider} bulk`); + assert.equal(shouldAttemptRotatingRefresh(provider, false), false, `${provider} explicit false`); + } +}); + +test("on-demand path (allowRotatingRefresh=true) may refresh rotating providers", () => { + for (const provider of ["codex", "openai", "claude"]) { + assert.equal(shouldAttemptRotatingRefresh(provider, true), true, `${provider} on-demand`); + } +}); + +test("non-rotating providers are always eligible to refresh regardless of the flag", () => { + for (const flag of [undefined, false, true] as const) { + assert.equal(shouldAttemptRotatingRefresh("github", flag), true); + assert.equal(shouldAttemptRotatingRefresh("cursor", flag), true); + } +});