Fix Codex auth import and reset credit details
# Conflicts: # app/(tabs)/codex.tsx # app/(tabs)/index.tsx # app/(tabs)/settings.tsx # hooks/useCodexUsage.ts # lib/api/codexApi.ts # package-lock.json # package.json
This commit is contained in:
+178
-48
@@ -1,66 +1,86 @@
|
||||
import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
|
||||
|
||||
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
||||
const RESET_CREDITS_URL =
|
||||
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
|
||||
|
||||
interface RawUsageWindow {
|
||||
used_percent?: number;
|
||||
limit_window_seconds?: number;
|
||||
reset_at?: number;
|
||||
}
|
||||
|
||||
interface RawUsageResponse {
|
||||
plan_type?: string | null;
|
||||
rate_limit?: {
|
||||
primary_window?: RawUsageWindow | null;
|
||||
secondary_window?: RawUsageWindow | null;
|
||||
} | null;
|
||||
credits?: {
|
||||
has_credits?: boolean;
|
||||
unlimited?: boolean;
|
||||
balance?: string | number;
|
||||
} | null;
|
||||
rate_limit_reached_type?: string | null;
|
||||
rate_limit_reset_credits?: {
|
||||
available_count?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface RawResetCredit {
|
||||
status?: string;
|
||||
granted_at?: string | null;
|
||||
expires_at?: string | null;
|
||||
}
|
||||
|
||||
interface RawResetCreditsResponse {
|
||||
available_count?: number | null;
|
||||
total_earned_count?: number | null;
|
||||
credits?: RawResetCredit[] | null;
|
||||
}
|
||||
|
||||
function isNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
export function parseCodexUsageResponse(data: unknown): CodexUsageResponse {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("INVALID_CODEX_RESPONSE");
|
||||
}
|
||||
function formatDurationUntil(isoString: string | null | undefined): string | null {
|
||||
if (!isoString) return null;
|
||||
|
||||
const value = data as Record<string, unknown>;
|
||||
const rateLimit = value.rate_limit as Record<string, unknown> | undefined;
|
||||
const primary = rateLimit?.primary_window as Record<string, unknown> | undefined;
|
||||
const secondary = rateLimit?.secondary_window as Record<string, unknown> | undefined;
|
||||
const credits = value.credits as Record<string, unknown> | undefined;
|
||||
const targetMs = new Date(isoString).getTime();
|
||||
if (Number.isNaN(targetMs)) return null;
|
||||
|
||||
const validWindow = (window: Record<string, unknown> | undefined) =>
|
||||
!!window &&
|
||||
isNumber(window.used_percent) &&
|
||||
isNumber(window.reset_at) &&
|
||||
isNumber(window.limit_window_seconds);
|
||||
let remainingSeconds = Math.max(Math.round((targetMs - Date.now()) / 1000), 0);
|
||||
const days = Math.floor(remainingSeconds / 86400);
|
||||
remainingSeconds -= days * 86400;
|
||||
const hours = Math.floor(remainingSeconds / 3600);
|
||||
remainingSeconds -= hours * 3600;
|
||||
const minutes = Math.floor(remainingSeconds / 60);
|
||||
remainingSeconds -= minutes * 60;
|
||||
|
||||
if (
|
||||
typeof value.plan_type !== "string" ||
|
||||
!validWindow(primary) ||
|
||||
!validWindow(secondary) ||
|
||||
!credits ||
|
||||
typeof credits.has_credits !== "boolean" ||
|
||||
typeof credits.unlimited !== "boolean" ||
|
||||
!isNumber(credits.balance)
|
||||
) {
|
||||
throw new Error("INVALID_CODEX_RESPONSE");
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
const primaryWindow = primary as Record<string, unknown>;
|
||||
const secondaryWindow = secondary as Record<string, unknown>;
|
||||
function normalizeWindow(window: RawUsageWindow | null | undefined) {
|
||||
if (!window) return null;
|
||||
|
||||
const windowSeconds =
|
||||
typeof window.limit_window_seconds === "number" ? window.limit_window_seconds : 0;
|
||||
const resetsAt = typeof window.reset_at === "number" ? window.reset_at : 0;
|
||||
|
||||
return {
|
||||
plan_type: value.plan_type,
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: primaryWindow.used_percent as number,
|
||||
reset_at: primaryWindow.reset_at as number,
|
||||
limit_window_seconds: primaryWindow.limit_window_seconds as number,
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: secondaryWindow.used_percent as number,
|
||||
reset_at: secondaryWindow.reset_at as number,
|
||||
limit_window_seconds: secondaryWindow.limit_window_seconds as number,
|
||||
},
|
||||
},
|
||||
credits: {
|
||||
has_credits: credits.has_credits,
|
||||
unlimited: credits.unlimited,
|
||||
balance: credits.balance,
|
||||
},
|
||||
usedPercent: typeof window.used_percent === "number" ? window.used_percent : 0,
|
||||
resetsAt,
|
||||
windowMinutes: Math.round(windowSeconds / 60),
|
||||
windowSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageResponse> {
|
||||
function buildBaseHeaders(auth: CodexAuth): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${auth.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
@@ -70,6 +90,103 @@ export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageRespon
|
||||
headers["ChatGPT-Account-Id"] = auth.accountId;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function fetchResetCoupons(auth: CodexAuth, usage: RawUsageResponse) {
|
||||
const headers = {
|
||||
...buildBaseHeaders(auth),
|
||||
"OpenAI-Beta": "codex-1",
|
||||
originator: "Codex Desktop",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(RESET_CREDITS_URL, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as RawResetCreditsResponse;
|
||||
const credits = Array.isArray(payload.credits) ? payload.credits : [];
|
||||
const normalizedCredits = credits
|
||||
.map((credit, index) => ({
|
||||
index: index + 1,
|
||||
status: credit.status,
|
||||
grantedAt: credit.granted_at ?? null,
|
||||
grantedAtLocal: credit.granted_at
|
||||
? new Date(credit.granted_at).toLocaleString()
|
||||
: null,
|
||||
expiresAt: credit.expires_at ?? null,
|
||||
expiresAtLocal: credit.expires_at
|
||||
? new Date(credit.expires_at).toLocaleString()
|
||||
: null,
|
||||
timeUntilExpiry: formatDurationUntil(credit.expires_at),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const left = a.expiresAt ? new Date(a.expiresAt).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
const right = b.expiresAt ? new Date(b.expiresAt).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
return left - right;
|
||||
});
|
||||
|
||||
const availableCredits = normalizedCredits.filter(
|
||||
(credit) => credit.status === "available"
|
||||
);
|
||||
|
||||
return {
|
||||
source: "live_api" as const,
|
||||
sourceDescription: "Live Codex reset-credit endpoint",
|
||||
availableCount:
|
||||
payload.available_count ?? usage.rate_limit_reset_credits?.available_count ?? null,
|
||||
totalEarnedCount: payload.total_earned_count ?? null,
|
||||
credits: normalizedCredits,
|
||||
nextExpiringCredit: availableCredits[0] ?? normalizedCredits[0] ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return {
|
||||
source: "unavailable" as const,
|
||||
sourceDescription: "Reset-credit endpoint unavailable",
|
||||
availableCount: usage.rate_limit_reset_credits?.available_count ?? null,
|
||||
fallbackReason: message,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCodexUsageResponse(data: unknown): CodexUsageResponse {
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("INVALID_CODEX_RESPONSE");
|
||||
}
|
||||
|
||||
const value = data as Record<string, unknown>;
|
||||
const primary = value.primary as Record<string, unknown> | null | undefined;
|
||||
const secondary = value.secondary as Record<string, unknown> | null | undefined;
|
||||
const credits = value.credits as Record<string, unknown> | undefined;
|
||||
|
||||
const validWindow = (window: Record<string, unknown> | null | undefined) =>
|
||||
window === null ||
|
||||
window === undefined ||
|
||||
(isNumber(window.usedPercent) &&
|
||||
isNumber(window.resetsAt) &&
|
||||
isNumber(window.windowMinutes) &&
|
||||
isNumber(window.windowSeconds));
|
||||
|
||||
if (
|
||||
!validWindow(primary) ||
|
||||
!validWindow(secondary) ||
|
||||
!credits ||
|
||||
typeof credits.hasCredits !== "boolean" ||
|
||||
typeof credits.unlimited !== "boolean" ||
|
||||
!isNumber(credits.balance)
|
||||
) {
|
||||
throw new Error("INVALID_CODEX_RESPONSE");
|
||||
}
|
||||
|
||||
return value as unknown as CodexUsageResponse;
|
||||
}
|
||||
|
||||
export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageResponse> {
|
||||
const headers = buildBaseHeaders(auth);
|
||||
const response = await fetch(USAGE_URL, { headers });
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
@@ -80,6 +197,19 @@ export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageRespon
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
return parseCodexUsageResponse(data);
|
||||
const payload = (await response.json()) as RawUsageResponse;
|
||||
const resetCoupons = await fetchResetCoupons(auth, payload);
|
||||
|
||||
return {
|
||||
planType: payload.plan_type ?? null,
|
||||
rateLimitReachedType: payload.rate_limit_reached_type ?? null,
|
||||
primary: normalizeWindow(payload.rate_limit?.primary_window),
|
||||
secondary: normalizeWindow(payload.rate_limit?.secondary_window),
|
||||
credits: {
|
||||
hasCredits: Boolean(payload.credits?.has_credits),
|
||||
unlimited: Boolean(payload.credits?.unlimited),
|
||||
balance: Number(payload.credits?.balance ?? 0),
|
||||
},
|
||||
resetCoupons,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user