mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user