feat(quota): live real-time codex quota on the page (cascade-safe serialized refresh)

Symptom: freshly-added Codex accounts (e.g. davi/gabriel) showed "No quota data"
even when healthy. Root cause: the quota path reuses the access_token without
refreshing rotating providers (#3019, anti Auth0 family-revocation cascade), so a
Codex account whose short-lived access_token has expired can never surface quota
from the sync — the live fetch returns "Codex token expired".

Fix (opt-in, cascade-safe):
- refreshAndUpdateCredentials gains `allowRotatingRefresh` + a pure exported gate
  `shouldAttemptRotatingRefresh`. The actual token mint is wrapped in
  `serializeRefresh` (one refresh at a time per Auth0 rotation group) — so even N
  concurrent per-account requests can never refresh siblings in parallel.
- The BULK scheduler (syncAllProviderLimits, concurrent) keeps the flag OFF →
  #3019 fully preserved (guardian test codex-quota-sync-no-proactive-refresh stays
  green). Only the on-demand, per-connection path (`GET /api/usage/[connectionId]`)
  opts in.
- Frontend: the quota page auto-fetches LIVE on open for the VISIBLE connections
  that have no cached quota (scoped to what's on screen — not all connections —
  and skips entries already cached), so expired-token Codex accounts surface real
  quota automatically and cascade-safely.

Adds unit coverage for the gate (bulk skips rotating, on-demand allows; non-rotating
always eligible). typecheck / lint clean.
This commit is contained in:
diegosouzapw
2026-06-02 09:53:50 -03:00
parent c4a993184e
commit e438139b03
4 changed files with 94 additions and 16 deletions

View File

@@ -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 {

View File

@@ -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 =

View File

@@ -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);

View File

@@ -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);
}
});