dc7e497388
# 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
216 lines
6.8 KiB
TypeScript
216 lines
6.8 KiB
TypeScript
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);
|
|
}
|
|
|
|
function formatDurationUntil(isoString: string | null | undefined): string | null {
|
|
if (!isoString) return null;
|
|
|
|
const targetMs = new Date(isoString).getTime();
|
|
if (Number.isNaN(targetMs)) return null;
|
|
|
|
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;
|
|
|
|
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(" ");
|
|
}
|
|
|
|
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 {
|
|
usedPercent: typeof window.used_percent === "number" ? window.used_percent : 0,
|
|
resetsAt,
|
|
windowMinutes: Math.round(windowSeconds / 60),
|
|
windowSeconds,
|
|
};
|
|
}
|
|
|
|
function buildBaseHeaders(auth: CodexAuth): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${auth.accessToken}`,
|
|
"Content-Type": "application/json",
|
|
};
|
|
|
|
if (auth.accountId) {
|
|
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) {
|
|
throw new Error("TOKEN_EXPIRED");
|
|
}
|
|
if (!response.ok) {
|
|
await response.text().catch(() => null);
|
|
throw new Error(`HTTP_ERROR_${response.status}`);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|