From 5cc4f222f8c012cde23e4291cbe0d20dd3f78eb5 Mon Sep 17 00:00:00 2001 From: Rafli Ahmad Zulfikar Date: Fri, 3 Jul 2026 15:02:28 +0700 Subject: [PATCH] feat(codex): show reset credit expiry details (#2290) Add read-only GET to inspect per-credit reset inventory (status, granted, expiry, remaining) with a Quota Tracker modal. DRY the route via shared connection/refresh helpers; keep existing consume POST unchanged. Co-authored-by: Cursor --- open-sse/providers/registry/codex.js | 1 + open-sse/services/usage.js | 4 +- open-sse/services/usage/codex.js | 56 +++++ .../usage/components/ProviderLimits/index.js | 179 +++++++++++++--- .../codex-reset-credits/route.js | 114 +++++++--- tests/unit/codex-reset-credits.test.js | 198 ++++++++++++++++++ 6 files changed, 495 insertions(+), 57 deletions(-) create mode 100644 tests/unit/codex-reset-credits.test.js diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js index 4620c4d3..0d2ddc05 100644 --- a/open-sse/providers/registry/codex.js +++ b/open-sse/providers/registry/codex.js @@ -40,6 +40,7 @@ export default { }, usage: { url: "https://chatgpt.com/backend-api/wham/usage", + resetCreditsUrl: "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", resetCreditsConsumeUrl: "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", }, }, diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index a4cf972a..4c56dc1b 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -5,9 +5,9 @@ import { getGitHubUsage } from "./usage/github.js"; import { getGeminiUsage, getAntigravityUsage } from "./usage/google.js"; import { getClaudeUsage } from "./usage/claude.js"; -import { getCodexUsage, consumeCodexRateLimitResetCredit } from "./usage/codex.js"; +import { getCodexUsage, consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits } from "./usage/codex.js"; -export { consumeCodexRateLimitResetCredit }; +export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits }; import { getKiroUsage } from "./usage/kiro.js"; import { getMiniMaxUsage } from "./usage/minimax.js"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js"; diff --git a/open-sse/services/usage/codex.js b/open-sse/services/usage/codex.js index cfcd5931..960af333 100644 --- a/open-sse/services/usage/codex.js +++ b/open-sse/services/usage/codex.js @@ -8,9 +8,23 @@ import { U, parseResetTime, toFiniteNumber } from "./shared.js"; // Codex (OpenAI) API config const CODEX_CONFIG = { usageUrl: U("codex").url, + resetCreditsUrl: U("codex").resetCreditsUrl, resetCreditsConsumeUrl: U("codex").resetCreditsConsumeUrl, }; +function toIsoDate(value) { + if (!value) return null; + const date = value instanceof Date + ? value + : new Date(typeof value === "number" && value < 1e12 ? value * 1000 : value); + const time = date.getTime(); + return Number.isFinite(time) ? date.toISOString() : null; +} + +function getCodexAccountId(providerSpecificData) { + return providerSpecificData?.workspaceId || providerSpecificData?.accountId || providerSpecificData?.chatgptAccountId || null; +} + function getCodexRateLimitBody(snapshot) { if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null; return snapshot.rate_limit && typeof snapshot.rate_limit === "object" @@ -101,6 +115,48 @@ export async function getCodexUsage(accessToken, proxyOptions = null) { } } +export async function getCodexRateLimitResetCredits(accessToken, proxyOptions = null, providerSpecificData = null) { + if (!accessToken) { + throw new Error("No Codex access token available. Please re-authorize the connection."); + } + + 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.resetCreditsUrl, { + method: "GET", + headers, + }, proxyOptions); + + let data = null; + try { + data = await response.json(); + } catch { + data = null; + } + + if (!response.ok) { + const message = data?.message || data?.error || data?.detail || `Codex reset credits API unavailable (${response.status}).`; + throw new Error(message); + } + + const credits = Array.isArray(data?.credits) ? data.credits : []; + return { + availableCount: Math.max(0, toFiniteNumber(data?.available_count ?? data?.availableCount, 0)), + credits: credits.map((credit) => ({ + status: String(credit?.status || "unknown"), + grantedAt: toIsoDate(credit?.granted_at ?? credit?.grantedAt), + expiresAt: toIsoDate(credit?.expires_at ?? credit?.expiresAt), + })), + }; +} + // Consume one Codex rate-limit reset credit (irreversible, spends 1 credit) export async function consumeCodexRateLimitResetCredit(accessToken, redeemRequestId, proxyOptions = null) { if (!accessToken) { diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 7d7c45fe..95300c92 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -97,6 +97,30 @@ function getCodexResetCreditCount(quota) { return Number.isFinite(count) ? Math.max(0, count) : 0; } +function formatCreditDate(value) { + if (!value) return "N/A"; + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return "N/A"; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function formatTimeRemaining(value) { + if (!value) return "N/A"; + const diffMs = new Date(value).getTime() - Date.now(); + if (!Number.isFinite(diffMs)) return "N/A"; + if (diffMs <= 0) return "Expired"; + const totalHours = Math.ceil(diffMs / (60 * 60 * 1000)); + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + return days > 0 ? `${days}d ${hours}h` : `${hours}h`; +} + export default function ProviderLimits() { const { copied, copy } = useCopyToClipboard(); const [connections, setConnections] = useState([]); @@ -114,6 +138,7 @@ export default function ProviderLimits() { const [togglingId, setTogglingId] = useState(null); const [resettingLimitId, setResettingLimitId] = useState(null); const [resetConfirmState, setResetConfirmState] = useState(null); + const [resetCreditsState, setResetCreditsState] = useState(null); const [showEditModal, setShowEditModal] = useState(false); const [selectedConnection, setSelectedConnection] = useState(null); const [proxyPools, setProxyPools] = useState([]); @@ -298,6 +323,26 @@ export default function ProviderLimits() { [fetchQuota, resettingLimitId], ); + const handleViewCodexResetCredits = useCallback(async (connection) => { + setResetCreditsState({ connection, loading: true, error: null, data: null }); + try { + const response = await fetch(`/api/usage/${connection.id}/codex-reset-credits`, { cache: "no-store" }); + const result = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(result.error || result.message || "Failed to load Codex reset credits"); + } + const credits = Array.isArray(result.credits) ? [...result.credits] : []; + credits.sort((a, b) => { + const aTime = a.expiresAt ? new Date(a.expiresAt).getTime() : Number.POSITIVE_INFINITY; + const bTime = b.expiresAt ? new Date(b.expiresAt).getTime() : Number.POSITIVE_INFINITY; + return aTime - bTime; + }); + setResetCreditsState({ connection, loading: false, error: null, data: { ...result, credits } }); + } catch (error) { + setResetCreditsState({ connection, loading: false, error: error.message || "Failed to load Codex reset credits", data: null }); + } + }, []); + const handleDeleteConnection = useCallback( async (id) => { if (!confirm("Delete this connection?")) return; @@ -1008,34 +1053,47 @@ export default function ProviderLimits() {
{isCodex && ( - 0 - ? `Use one Codex reset credit. Available: ${resetCreditCount}` - : "No Codex reset credits available" - } - > - - + + + + + + )} {AUTO_PING_SETTINGS_KEYS[conn.provider] && conn.authType === "oauth" && ( @@ -1292,6 +1350,79 @@ export default function ProviderLimits() { loading={Boolean(resettingLimitId)} /> + {resetCreditsState && ( +
+
+
+
+

Codex Reset Credit Expiry

+

+ {getConnectionLabel(resetCreditsState.connection) || "Codex account"} +

+
+ +
+ +
+ {resetCreditsState.loading ? ( +
+ progress_activity + Loading reset credits... +
+ ) : resetCreditsState.error ? ( +
+ {resetCreditsState.error} +
+ ) : resetCreditsState.data?.credits?.length ? ( +
+
+ {resetCreditsState.data.credits.length} reset credit{resetCreditsState.data.credits.length === 1 ? "" : "s"} + {resetCreditsState.data.availableCount ?? 0} available +
+
+ + + + + + + + + + + {resetCreditsState.data.credits.map((credit, index) => ( + + + + + + + ))} + +
StatusGranted AtExpires AtRemaining
+ + {credit.status || "unknown"} + + {formatCreditDate(credit.grantedAt)}{formatCreditDate(credit.expiresAt)}{formatTimeRemaining(credit.expiresAt)}
+
+
+ ) : ( +
+ No reset credit details returned for this account. +
+ )} +
+
+
+ )} + AUTH_EXPIRED_PATTERNS.some((pattern) => value.includes(pattern))); } +function isAuthExpiredError(error) { + return isAuthExpiredResult({ message: error?.message }); +} + function getResponseForConsumeResult(result, redeemRequestId) { if (result.ok) { return Response.json({ @@ -43,42 +47,90 @@ function getResponseForConsumeResult(result, redeemRequestId) { }, { status: result.status >= 400 && result.status < 500 ? result.status : 502 }); } +async function getCodexConnection(connectionId) { + const connection = await getProviderConnectionById(connectionId); + if (!connection) { + return { response: Response.json({ error: "Connection not found" }, { status: 404 }) }; + } + + if (connection.provider !== "codex") { + return { response: Response.json({ error: "Codex reset credits are only available for Codex connections." }, { status: 400 }) }; + } + + const isOAuth = connection.authType === "oauth"; + const isAccessToken = connection.authType === "access_token"; + if (!isOAuth && !isAccessToken) { + return { response: Response.json({ error: "Codex reset credits require an OAuth or access-token connection." }, { status: 400 }) }; + } + + const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData); + const proxyOptions = { + connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true, + connectionProxyUrl: proxyConfig.connectionProxyUrl || "", + connectionNoProxy: proxyConfig.connectionNoProxy || "", + vercelRelayUrl: proxyConfig.vercelRelayUrl || "", + strictProxy: false, + }; + + return { connection, isOAuth, proxyOptions }; +} + +async function refreshCodexConnection(connection, proxyOptions) { + try { + const result = await refreshAndUpdateCredentials(connection, false, proxyOptions); + return { connection: result.connection }; + } catch (refreshError) { + console.error("[Codex Reset Credits API] Credential refresh failed:", refreshError); + return { response: Response.json({ error: `Credential refresh failed: ${refreshError.message}` }, { status: 401 }) }; + } +} + +export async function GET(_request, { params }) { + let connection; + try { + const { connectionId } = await params; + const resolved = await getCodexConnection(connectionId); + if (resolved.response) return resolved.response; + ({ connection } = resolved); + const { isOAuth, proxyOptions } = resolved; + + if (isOAuth) { + const refreshed = await refreshCodexConnection(connection, proxyOptions); + if (refreshed.response) return refreshed.response; + connection = refreshed.connection; + } + + let result; + try { + result = await getCodexRateLimitResetCredits(connection.accessToken, proxyOptions, connection.providerSpecificData); + } catch (fetchError) { + if (!isOAuth || !connection.refreshToken || !isAuthExpiredError(fetchError)) throw fetchError; + const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions); + connection = retryResult.connection; + result = await getCodexRateLimitResetCredits(connection.accessToken, proxyOptions, connection.providerSpecificData); + } + + return Response.json(result); + } catch (error) { + const provider = connection?.provider ?? "unknown"; + console.warn(`[Codex Reset Credits] ${provider}: ${error.message}`); + return Response.json({ error: error.message }, { status: 500 }); + } +} + export async function POST(request, { params }) { let connection; try { const { connectionId } = await params; - connection = await getProviderConnectionById(connectionId); - if (!connection) { - return Response.json({ error: "Connection not found" }, { status: 404 }); - } - - if (connection.provider !== "codex") { - return Response.json({ error: "Codex reset credits are only available for Codex connections." }, { status: 400 }); - } - - const isOAuth = connection.authType === "oauth"; - const isAccessToken = connection.authType === "access_token"; - if (!isOAuth && !isAccessToken) { - return Response.json({ error: "Codex reset credits require an OAuth or access-token connection." }, { status: 400 }); - } - - const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData); - const proxyOptions = { - connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true, - connectionProxyUrl: proxyConfig.connectionProxyUrl || "", - connectionNoProxy: proxyConfig.connectionNoProxy || "", - vercelRelayUrl: proxyConfig.vercelRelayUrl || "", - strictProxy: false, - }; + const resolved = await getCodexConnection(connectionId); + if (resolved.response) return resolved.response; + ({ connection } = resolved); + const { isOAuth, proxyOptions } = resolved; if (isOAuth) { - try { - const result = await refreshAndUpdateCredentials(connection, false, proxyOptions); - connection = result.connection; - } catch (refreshError) { - console.error("[Codex Reset Credits API] Credential refresh failed:", refreshError); - return Response.json({ error: `Credential refresh failed: ${refreshError.message}` }, { status: 401 }); - } + const refreshed = await refreshCodexConnection(connection, proxyOptions); + if (refreshed.response) return refreshed.response; + connection = refreshed.connection; } // Server-generated redeem id prevents client-controlled replay diff --git a/tests/unit/codex-reset-credits.test.js b/tests/unit/codex-reset-credits.test.js new file mode 100644 index 00000000..c8b4c6fd --- /dev/null +++ b/tests/unit/codex-reset-credits.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + proxyAwareFetch: vi.fn(), + getProviderConnectionById: vi.fn(), + resolveConnectionProxyConfig: vi.fn(), + refreshAndUpdateCredentials: vi.fn(), + getCodexRateLimitResetCredits: vi.fn(), + consumeCodexRateLimitResetCredit: vi.fn(), +})); + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: mocks.proxyAwareFetch, +})); + +vi.mock("open-sse/index.js", () => ({})); + +vi.mock("@/lib/localDb", () => ({ + getProviderConnectionById: mocks.getProviderConnectionById, +})); + +vi.mock("@/lib/network/connectionProxy", () => ({ + resolveConnectionProxyConfig: mocks.resolveConnectionProxyConfig, +})); + +vi.mock("@/app/api/usage/[connectionId]/route.js", () => ({ + refreshAndUpdateCredentials: mocks.refreshAndUpdateCredentials, +})); + +vi.mock("open-sse/services/usage.js", () => ({ + getCodexRateLimitResetCredits: mocks.getCodexRateLimitResetCredits, + consumeCodexRateLimitResetCredit: mocks.consumeCodexRateLimitResetCredit, +})); + +describe("Codex reset credits", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.resolveConnectionProxyConfig.mockResolvedValue({}); + }); + + it("returns normalized reset credit expiry details", async () => { + mocks.proxyAwareFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + available_count: 2, + credits: [ + { + status: "available", + granted_at: "2026-06-18T00:25:18Z", + expires_at: "2026-07-18T00:25:18Z", + }, + { + status: "redeemed", + granted_at: "bad-date", + expires_at: null, + }, + ], + }), + }); + + const { getCodexRateLimitResetCredits } = await import("../../open-sse/services/usage/codex.js"); + const result = await getCodexRateLimitResetCredits("token", { strictProxy: false }, { workspaceId: "acct_123" }); + + expect(mocks.proxyAwareFetch).toHaveBeenCalledWith( + expect.stringContaining("/rate-limit-reset-credits"), + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer token", + "ChatGPT-Account-ID": "acct_123", + }), + }), + { strictProxy: false }, + ); + expect(result).toEqual({ + availableCount: 2, + credits: [ + { + status: "available", + grantedAt: "2026-06-18T00:25:18.000Z", + expiresAt: "2026-07-18T00:25:18.000Z", + }, + { + status: "redeemed", + grantedAt: null, + expiresAt: null, + }, + ], + }); + }); + + it("GET refreshes OAuth credentials before returning reset credit details", async () => { + const connection = { + id: "conn_1", + provider: "codex", + authType: "oauth", + accessToken: "old-token", + refreshToken: "refresh-token", + providerSpecificData: { workspaceId: "acct_123" }, + }; + const refreshedConnection = { ...connection, accessToken: "new-token" }; + const resetCredits = { + availableCount: 1, + credits: [{ status: "available", grantedAt: "2026-06-18T00:25:18.000Z", expiresAt: "2026-07-18T00:25:18.000Z" }], + }; + mocks.getProviderConnectionById.mockResolvedValue(connection); + mocks.resolveConnectionProxyConfig.mockResolvedValue({ connectionProxyEnabled: true, connectionProxyUrl: "http://proxy.local" }); + mocks.refreshAndUpdateCredentials.mockResolvedValue({ connection: refreshedConnection }); + mocks.getCodexRateLimitResetCredits.mockResolvedValue(resetCredits); + + const { GET } = await import("../../src/app/api/usage/[connectionId]/codex-reset-credits/route.js"); + const response = await GET(new Request("http://localhost/api/usage/conn_1/codex-reset-credits"), { + params: Promise.resolve({ connectionId: "conn_1" }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(resetCredits); + expect(mocks.refreshAndUpdateCredentials).toHaveBeenCalledWith( + connection, + false, + expect.objectContaining({ connectionProxyEnabled: true, connectionProxyUrl: "http://proxy.local", strictProxy: false }), + ); + expect(mocks.getCodexRateLimitResetCredits).toHaveBeenCalledWith( + "new-token", + expect.objectContaining({ connectionProxyEnabled: true, connectionProxyUrl: "http://proxy.local", strictProxy: false }), + { workspaceId: "acct_123" }, + ); + }); + + it("GET force-refreshes OAuth credentials when reset credit fetch reports expired auth", async () => { + const connection = { + id: "conn_1", + provider: "codex", + authType: "oauth", + accessToken: "old-token", + refreshToken: "refresh-token", + providerSpecificData: {}, + }; + const refreshedConnection = { ...connection, accessToken: "new-token" }; + const forcedConnection = { ...connection, accessToken: "forced-token" }; + const resetCredits = { availableCount: 0, credits: [] }; + mocks.getProviderConnectionById.mockResolvedValue(connection); + mocks.refreshAndUpdateCredentials + .mockResolvedValueOnce({ connection: refreshedConnection }) + .mockResolvedValueOnce({ connection: forcedConnection }); + mocks.getCodexRateLimitResetCredits + .mockRejectedValueOnce(new Error("Unauthorized 401")) + .mockResolvedValueOnce(resetCredits); + + const { GET } = await import("../../src/app/api/usage/[connectionId]/codex-reset-credits/route.js"); + const response = await GET(new Request("http://localhost/api/usage/conn_1/codex-reset-credits"), { + params: Promise.resolve({ connectionId: "conn_1" }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(resetCredits); + expect(mocks.refreshAndUpdateCredentials).toHaveBeenNthCalledWith(1, connection, false, expect.any(Object)); + expect(mocks.refreshAndUpdateCredentials).toHaveBeenNthCalledWith(2, refreshedConnection, true, expect.any(Object)); + expect(mocks.getCodexRateLimitResetCredits).toHaveBeenNthCalledWith(2, "forced-token", expect.any(Object), {}); + }); + + it("POST returns 409 when there are no reset credits to consume", async () => { + mocks.getProviderConnectionById.mockResolvedValue({ + id: "conn_1", + provider: "codex", + authType: "access_token", + accessToken: "token", + providerSpecificData: {}, + }); + mocks.consumeCodexRateLimitResetCredit.mockResolvedValue({ + ok: false, + noCredit: true, + status: 200, + code: "no_credit", + windowsReset: 0, + }); + + const { POST } = await import("../../src/app/api/usage/[connectionId]/codex-reset-credits/route.js"); + const response = await POST(new Request("http://localhost/api/usage/conn_1/codex-reset-credits", { method: "POST" }), { + params: Promise.resolve({ connectionId: "conn_1" }), + }); + + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + code: "no_credit", + reset: false, + windows_reset: 0, + message: "No Codex reset credits available.", + }); + expect(mocks.consumeCodexRateLimitResetCredit).toHaveBeenCalledWith( + "token", + expect.any(String), + expect.objectContaining({ strictProxy: false }), + ); + }); +});