mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
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 <cursoragent@cursor.com>
This commit is contained in:
committed by
decolua
co-authored by
Cursor
parent
cd557a2552
commit
5cc4f222f8
@@ -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() {
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isCodex && (
|
||||
<Tooltip
|
||||
text={
|
||||
resetCreditCount > 0
|
||||
? `Use one Codex reset credit. Available: ${resetCreditCount}`
|
||||
: "No Codex reset credits available"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResetConfirmState({ connection: conn, resetCreditCount })}
|
||||
disabled={resetCreditCount <= 0 || isLoading || rowBusy}
|
||||
aria-label={
|
||||
<>
|
||||
<Tooltip
|
||||
text={
|
||||
resetCreditCount > 0
|
||||
? `Use one Codex reset credit. ${resetCreditCount} available.`
|
||||
? `Use one Codex reset credit. Available: ${resetCreditCount}`
|
||||
: "No Codex reset credits available"
|
||||
}
|
||||
className={`flex h-8 min-w-10 items-center justify-center gap-1 rounded-lg border px-2 text-[11px] font-medium tabular-nums transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/60 disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
resetCreditCount > 0
|
||||
? "border-primary/30 bg-primary/5 text-primary hover:bg-primary/10"
|
||||
: "border-black/10 bg-black/[0.02] text-text-muted dark:border-white/10 dark:bg-white/[0.03]"
|
||||
}`}
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[15px] ${isResettingLimit ? "animate-spin" : ""}`}>
|
||||
{isResettingLimit ? "progress_activity" : "restart_alt"}
|
||||
</span>
|
||||
<span>{resetCreditCount}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResetConfirmState({ connection: conn, resetCreditCount })}
|
||||
disabled={resetCreditCount <= 0 || isLoading || rowBusy}
|
||||
aria-label={
|
||||
resetCreditCount > 0
|
||||
? `Use one Codex reset credit. ${resetCreditCount} available.`
|
||||
: "No Codex reset credits available"
|
||||
}
|
||||
className={`flex h-8 min-w-10 items-center justify-center gap-1 rounded-lg border px-2 text-[11px] font-medium tabular-nums transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/60 disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
resetCreditCount > 0
|
||||
? "border-primary/30 bg-primary/5 text-primary hover:bg-primary/10"
|
||||
: "border-black/10 bg-black/[0.02] text-text-muted dark:border-white/10 dark:bg-white/[0.03]"
|
||||
}`}
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[15px] ${isResettingLimit ? "animate-spin" : ""}`}>
|
||||
{isResettingLimit ? "progress_activity" : "restart_alt"}
|
||||
</span>
|
||||
<span>{resetCreditCount}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip text="View Codex reset credit expiry">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewCodexResetCredits(conn)}
|
||||
disabled={isLoading || rowBusy}
|
||||
aria-label="View Codex reset credit expiry"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-black/10 text-text-muted transition-colors hover:bg-black/5 hover:text-primary disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:hover:bg-white/5"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[17px]">schedule</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{AUTO_PING_SETTINGS_KEYS[conn.provider] && conn.authType === "oauth" && (
|
||||
<Tooltip text={AUTO_PING_TOOLTIPS[conn.provider]}>
|
||||
@@ -1292,6 +1350,79 @@ export default function ProviderLimits() {
|
||||
loading={Boolean(resettingLimitId)}
|
||||
/>
|
||||
|
||||
{resetCreditsState && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-2xl overflow-hidden rounded-2xl border border-black/15 bg-white shadow-2xl ring-1 ring-black/10 dark:border-white/15 dark:bg-neutral-950 dark:ring-white/10">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-black/10 bg-black/[0.03] px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-text-primary">Codex Reset Credit Expiry</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-text-muted">
|
||||
{getConnectionLabel(resetCreditsState.connection) || "Codex account"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResetCreditsState(null)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-text-muted transition-colors hover:bg-black/5 hover:text-text-primary dark:hover:bg-white/5"
|
||||
aria-label="Close reset credit expiry modal"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[70vh] overflow-auto bg-white p-4 dark:bg-neutral-950">
|
||||
{resetCreditsState.loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin text-[20px]">progress_activity</span>
|
||||
Loading reset credits...
|
||||
</div>
|
||||
) : resetCreditsState.error ? (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-300">
|
||||
{resetCreditsState.error}
|
||||
</div>
|
||||
) : resetCreditsState.data?.credits?.length ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-xl border border-black/10 bg-black/[0.02] px-3 py-2 text-xs text-text-muted dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<span>{resetCreditsState.data.credits.length} reset credit{resetCreditsState.data.credits.length === 1 ? "" : "s"}</span>
|
||||
<span>{resetCreditsState.data.availableCount ?? 0} available</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-black/10 dark:border-white/10">
|
||||
<table className="w-full min-w-[560px] text-left text-sm">
|
||||
<thead className="bg-black/[0.03] text-xs uppercase tracking-wide text-text-muted dark:bg-white/[0.04]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Granted At</th>
|
||||
<th className="px-3 py-2 font-medium">Expires At</th>
|
||||
<th className="px-3 py-2 font-medium">Remaining</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resetCreditsState.data.credits.map((credit, index) => (
|
||||
<tr key={`${credit.status}-${credit.expiresAt || index}`} className="border-t border-black/5 dark:border-white/5">
|
||||
<td className="px-3 py-2">
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{credit.status || "unknown"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-text-muted">{formatCreditDate(credit.grantedAt)}</td>
|
||||
<td className="px-3 py-2 text-text-primary">{formatCreditDate(credit.expiresAt)}</td>
|
||||
<td className="px-3 py-2 font-medium text-text-primary">{formatTimeRemaining(credit.expiresAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-black/10 bg-black/[0.02] px-3 py-8 text-center text-sm text-text-muted dark:border-white/10 dark:bg-white/[0.03]">
|
||||
No reset credit details returned for this account.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EditConnectionModal
|
||||
isOpen={showEditModal}
|
||||
connection={selectedConnection}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import "open-sse/index.js";
|
||||
|
||||
import { getProviderConnectionById } from "@/lib/localDb";
|
||||
import { consumeCodexRateLimitResetCredit } from "open-sse/services/usage.js";
|
||||
import { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits } from "open-sse/services/usage.js";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
import { refreshAndUpdateCredentials } from "../route.js";
|
||||
|
||||
@@ -15,6 +15,10 @@ function isAuthExpiredResult(result) {
|
||||
return values.some((value) => 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
|
||||
|
||||
Reference in New Issue
Block a user