Fix Codex auth import and reset credit details
CodexBar Mobile Build / build-android (push) Successful in 17m26s
CodexBar Mobile Build / release (push) Successful in 12s

# 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:
Space-Banane
2026-07-04 18:59:56 +02:00
parent 5a00822690
commit dc7e497388
13 changed files with 837 additions and 498 deletions
+178 -48
View File
@@ -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,
};
}
+42 -4
View File
@@ -1,6 +1,41 @@
import * as DocumentPicker from "expo-document-picker";
import * as LegacyFileSystem from "expo-file-system/legacy";
import type { CodexAuth } from "@/types/codex";
function sanitizeFileName(name: string): string {
return name.replace(/[^a-zA-Z0-9._-]/g, "_");
}
async function readPickedTextFile(uri: string, fileName?: string): Promise<string> {
try {
const response = await fetch(uri);
if (!response.ok) {
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.text();
} catch {
try {
return await LegacyFileSystem.readAsStringAsync(uri);
} catch {
const cacheDirectory = LegacyFileSystem.cacheDirectory;
if (!cacheDirectory) {
throw new Error("DOCUMENT_NOT_READABLE");
}
const destination = `${cacheDirectory}${Date.now()}-${sanitizeFileName(
fileName ?? "import.json"
)}`;
try {
await LegacyFileSystem.copyAsync({ from: uri, to: destination });
return await LegacyFileSystem.readAsStringAsync(destination);
} catch {
throw new Error("DOCUMENT_NOT_READABLE");
}
}
}
}
export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
const result = await DocumentPicker.getDocumentAsync({
type: "application/json",
@@ -9,9 +44,8 @@ export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
if (result.canceled) throw new Error("PICKER_CANCELLED");
const { uri } = result.assets[0];
const response = await fetch(uri);
const text = await response.text();
const { uri, name } = result.assets[0];
const text = await readPickedTextFile(uri, name);
let parsed: Record<string, unknown>;
try {
@@ -22,7 +56,11 @@ export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
const tokens = parsed["tokens"] as Record<string, unknown> | undefined;
const accessToken = (tokens?.["access_token"] ?? parsed["accessToken"]) as string | undefined;
const accountId = (tokens?.["account_id"] ?? parsed["accountId"]) as string | undefined;
const accountId = (
tokens?.["account_id"] ??
parsed["accountId"] ??
parsed["account_id"]
) as string | undefined;
if (!accessToken || typeof accessToken !== "string") {
throw new Error("MISSING_ACCESS_TOKEN");
+2 -2
View File
@@ -56,7 +56,7 @@ export async function scheduleDailyDigest(
}
if (codexUsage) {
parts.push(
`Codex: ${Math.round(codexUsage.rate_limit.secondary_window.used_percent)}% used`
`Codex: ${Math.round(codexUsage.secondary?.usedPercent ?? 0)}% used`
);
}
@@ -106,7 +106,7 @@ function buildThresholdAlerts(
}
if (codexUsage) {
const remaining = 100 - codexUsage.rate_limit.secondary_window.used_percent;
const remaining = 100 - (codexUsage.secondary?.usedPercent ?? 0);
if (remaining <= thresholdPct) {
alerts.push({ service: "Codex", window: "weekly", remaining });
}