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
+
+
+
+
+
+ | Status |
+ Granted At |
+ Expires At |
+ Remaining |
+
+
+
+ {resetCreditsState.data.credits.map((credit, index) => (
+
+ |
+
+ {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 }),
+ );
+ });
+});