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:
+98
-12
@@ -15,6 +15,44 @@ import { LastUpdated } from "@/components/LastUpdated";
|
|||||||
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
||||||
import { COLORS } from "@/lib/constants";
|
import { COLORS } from "@/lib/constants";
|
||||||
import { windowLabel } from "@/lib/timeUtils";
|
import { windowLabel } from "@/lib/timeUtils";
|
||||||
|
import type { CodexResetCredit } from "@/types/codex";
|
||||||
|
|
||||||
|
function formatResetStatus(status?: string) {
|
||||||
|
if (!status) return "Unknown";
|
||||||
|
return status.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetCreditRow({ credit }: { credit: CodexResetCredit }) {
|
||||||
|
return (
|
||||||
|
<View className="mt-2 rounded-xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 p-3">
|
||||||
|
<View className="flex-row items-center justify-between mb-1.5">
|
||||||
|
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||||
|
Credit {credit.index}
|
||||||
|
</Text>
|
||||||
|
<View className="px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30">
|
||||||
|
<Text className="text-[11px] font-semibold text-green-700 dark:text-green-300">
|
||||||
|
{formatResetStatus(credit.status)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{credit.timeUntilExpiry && (
|
||||||
|
<Text className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Expires in {credit.timeUntilExpiry}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{credit.expiresAtLocal && (
|
||||||
|
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
|
Expires {credit.expiresAtLocal}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{credit.grantedAtLocal && (
|
||||||
|
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
|
Granted {credit.grantedAtLocal}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CodexTabContent() {
|
function CodexTabContent() {
|
||||||
const {
|
const {
|
||||||
@@ -28,6 +66,9 @@ function CodexTabContent() {
|
|||||||
clearAuth,
|
clearAuth,
|
||||||
} =
|
} =
|
||||||
useCodexUsage();
|
useCodexUsage();
|
||||||
|
const resetCoupons = usage?.resetCoupons;
|
||||||
|
const availableResetCredits =
|
||||||
|
resetCoupons?.credits?.filter((credit) => credit.status === "available") ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
@@ -134,21 +175,25 @@ function CodexTabContent() {
|
|||||||
<View className="flex-row items-center mb-5">
|
<View className="flex-row items-center mb-5">
|
||||||
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/40">
|
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/40">
|
||||||
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
||||||
{usage.plan_type}
|
{usage.planType ?? "codex"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<UsageStat
|
{usage.primary && (
|
||||||
label={`Primary (${windowLabel(usage.rate_limit.primary_window.limit_window_seconds)})`}
|
<UsageStat
|
||||||
percent={usage.rate_limit.primary_window.used_percent}
|
label={`Primary (${windowLabel(usage.primary.windowSeconds)})`}
|
||||||
resetAtSeconds={usage.rate_limit.primary_window.reset_at}
|
percent={usage.primary.usedPercent}
|
||||||
/>
|
resetAtSeconds={usage.primary.resetsAt}
|
||||||
<UsageStat
|
/>
|
||||||
label={`Secondary (${windowLabel(usage.rate_limit.secondary_window.limit_window_seconds)})`}
|
)}
|
||||||
percent={usage.rate_limit.secondary_window.used_percent}
|
{usage.secondary && (
|
||||||
resetAtSeconds={usage.rate_limit.secondary_window.reset_at}
|
<UsageStat
|
||||||
/>
|
label={`Secondary (${windowLabel(usage.secondary.windowSeconds)})`}
|
||||||
|
percent={usage.secondary.usedPercent}
|
||||||
|
resetAtSeconds={usage.secondary.resetsAt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Credits */}
|
{/* Credits */}
|
||||||
<View className="mt-1 p-3.5 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-100 dark:border-neutral-700">
|
<View className="mt-1 p-3.5 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-100 dark:border-neutral-700">
|
||||||
@@ -162,7 +207,7 @@ function CodexTabContent() {
|
|||||||
Unlimited
|
Unlimited
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : usage.credits.has_credits ? (
|
) : usage.credits.hasCredits ? (
|
||||||
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||||
${Number(usage.credits.balance).toFixed(2)} remaining
|
${Number(usage.credits.balance).toFixed(2)} remaining
|
||||||
</Text>
|
</Text>
|
||||||
@@ -170,6 +215,47 @@ function CodexTabContent() {
|
|||||||
<Text className="text-sm text-neutral-400">No credits</Text>
|
<Text className="text-sm text-neutral-400">No credits</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{resetCoupons && (
|
||||||
|
<View className="mt-3 p-3.5 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-100 dark:border-neutral-700">
|
||||||
|
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-2">
|
||||||
|
Reset Credits
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||||
|
{resetCoupons.availableCount ?? 0} available
|
||||||
|
</Text>
|
||||||
|
{typeof resetCoupons.totalEarnedCount === "number" && (
|
||||||
|
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
|
{resetCoupons.totalEarnedCount} earned total
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{resetCoupons.nextExpiringCredit?.expiresAtLocal && (
|
||||||
|
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
|
Next expires {resetCoupons.nextExpiringCredit.expiresAtLocal}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{resetCoupons.nextExpiringCredit?.timeUntilExpiry && (
|
||||||
|
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-0.5">
|
||||||
|
In {resetCoupons.nextExpiringCredit.timeUntilExpiry}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{availableResetCredits.length > 0 && (
|
||||||
|
<View className="mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-1">
|
||||||
|
Available Now
|
||||||
|
</Text>
|
||||||
|
{availableResetCredits.map((credit) => (
|
||||||
|
<ResetCreditRow key={`${credit.index}-${credit.expiresAt ?? "none"}`} credit={credit} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{resetCoupons.source !== "live_api" && (
|
||||||
|
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
|
||||||
|
Source: {resetCoupons.sourceDescription}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<View className="mt-4 border-t border-neutral-100 pt-3 dark:border-neutral-800">
|
<View className="mt-4 border-t border-neutral-100 pt-3 dark:border-neutral-800">
|
||||||
<LastUpdated timestamp={lastFetchedAt} />
|
<LastUpdated timestamp={lastFetchedAt} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
+34
-18
@@ -159,17 +159,17 @@ function DashboardTabContent() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row items-center gap-x-2">
|
<View className="flex-row items-center gap-x-2">
|
||||||
{codex.usage?.plan_type && (
|
{codex.usage?.planType && (
|
||||||
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/30">
|
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/30">
|
||||||
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
||||||
{codex.usage.plan_type}
|
{codex.usage.planType}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{codex.auth && codex.status === "error" && (
|
{codex.auth && codex.status === "error" && (
|
||||||
<View className="w-2 h-2 rounded-full bg-red-500" />
|
<View className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
)}
|
)}
|
||||||
{codex.status === "success" && !codex.usage?.plan_type && (
|
{codex.status === "success" && !codex.usage?.planType && (
|
||||||
<View className="w-2 h-2 rounded-full bg-green-500" />
|
<View className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -189,22 +189,22 @@ function DashboardTabContent() {
|
|||||||
)}
|
)}
|
||||||
{codex.usage && (
|
{codex.usage && (
|
||||||
<View>
|
<View>
|
||||||
<UsageRow
|
{codex.usage.primary && (
|
||||||
label={`${windowLabel(codex.usage.rate_limit.primary_window.limit_window_seconds)} window`}
|
<UsageRow
|
||||||
percent={codex.usage.rate_limit.primary_window.used_percent}
|
label={`${windowLabel(codex.usage.primary.windowSeconds)} window`}
|
||||||
resetAtSeconds={codex.usage.rate_limit.primary_window.reset_at}
|
percent={codex.usage.primary.usedPercent}
|
||||||
/>
|
resetAtSeconds={codex.usage.primary.resetsAt}
|
||||||
<UsageRow
|
/>
|
||||||
label={`${windowLabel(codex.usage.rate_limit.secondary_window.limit_window_seconds)} window`}
|
)}
|
||||||
percent={
|
{codex.usage.secondary && (
|
||||||
codex.usage.rate_limit.secondary_window.used_percent
|
<UsageRow
|
||||||
}
|
label={`${windowLabel(codex.usage.secondary.windowSeconds)} window`}
|
||||||
resetAtSeconds={
|
percent={codex.usage.secondary.usedPercent}
|
||||||
codex.usage.rate_limit.secondary_window.reset_at
|
resetAtSeconds={codex.usage.secondary.resetsAt}
|
||||||
}
|
/>
|
||||||
/>
|
)}
|
||||||
{!codex.usage.credits.unlimited &&
|
{!codex.usage.credits.unlimited &&
|
||||||
codex.usage.credits.has_credits && (
|
codex.usage.credits.hasCredits && (
|
||||||
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800 flex-row items-center gap-x-2">
|
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800 flex-row items-center gap-x-2">
|
||||||
<MaterialIcons name="toll" size={14} color="#a3a3a3" />
|
<MaterialIcons name="toll" size={14} color="#a3a3a3" />
|
||||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
|
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
@@ -226,6 +226,22 @@ function DashboardTabContent() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className="mt-3 border-t border-neutral-100 pt-3 dark:border-neutral-800">
|
<View className="mt-3 border-t border-neutral-100 pt-3 dark:border-neutral-800">
|
||||||
|
{!!codex.usage.resetCoupons && (
|
||||||
|
<View className="mb-3">
|
||||||
|
<View className="flex-row items-center gap-x-2">
|
||||||
|
<MaterialIcons name="restart-alt" size={14} color={COLORS.codex} />
|
||||||
|
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{codex.usage.resetCoupons.availableCount ?? 0} reset credits
|
||||||
|
available
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!!codex.usage.resetCoupons.nextExpiringCredit?.timeUntilExpiry && (
|
||||||
|
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||||
|
Next expires in {codex.usage.resetCoupons.nextExpiringCredit.timeUntilExpiry}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<LastUpdated timestamp={codex.lastFetchedAt} />
|
<LastUpdated timestamp={codex.lastFetchedAt} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
|||||||
import type { CodexAuth } from "@/types/codex";
|
import type { CodexAuth } from "@/types/codex";
|
||||||
import Constants from "expo-constants";
|
import Constants from "expo-constants";
|
||||||
|
|
||||||
|
function humanizeImportError(code: string): string {
|
||||||
|
if (code === "DOCUMENT_NOT_READABLE") {
|
||||||
|
return "The selected file could not be read. Try copying auth.json into Files or Downloads and import it again.";
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
function SettingRow({
|
function SettingRow({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
@@ -160,7 +166,7 @@ function SettingsTabContent() {
|
|||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||||
if (msg !== "PICKER_CANCELLED") {
|
if (msg !== "PICKER_CANCELLED") {
|
||||||
Alert.alert("Import failed", msg);
|
Alert.alert("Import failed", humanizeImportError(msg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ import { saveCodexAuth, loadCodexAuth } from "@/lib/storage";
|
|||||||
import type { CodexAuth } from "@/types/codex";
|
import type { CodexAuth } from "@/types/codex";
|
||||||
import { COLORS } from "@/lib/constants";
|
import { COLORS } from "@/lib/constants";
|
||||||
|
|
||||||
|
function humanizeImportError(code: string): string {
|
||||||
|
if (code === "DOCUMENT_NOT_READABLE") {
|
||||||
|
return "The selected file could not be read. Try copying auth.json into Files or Downloads and import it again.";
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
export default function OnboardingCodexScreen() {
|
export default function OnboardingCodexScreen() {
|
||||||
const [auth, setAuth] = useState<CodexAuth | null>(null);
|
const [auth, setAuth] = useState<CodexAuth | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -28,7 +35,7 @@ export default function OnboardingCodexScreen() {
|
|||||||
setAuth(parsed);
|
setAuth(parsed);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||||
if (msg !== "PICKER_CANCELLED") setError(msg);
|
if (msg !== "PICKER_CANCELLED") setError(humanizeImportError(msg));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -86,7 +93,7 @@ export default function OnboardingCodexScreen() {
|
|||||||
<MaterialIcons name="check-circle" size={22} color={COLORS.codex} />
|
<MaterialIcons name="check-circle" size={22} color={COLORS.codex} />
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text className="text-white font-semibold">Connected</Text>
|
<Text className="text-white font-semibold">Imported</Text>
|
||||||
{auth.accountId && (
|
{auth.accountId && (
|
||||||
<Text className="text-neutral-500 text-sm font-mono">
|
<Text className="text-neutral-500 text-sm font-mono">
|
||||||
{auth.accountId.slice(0, 12)}…
|
{auth.accountId.slice(0, 12)}…
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function OnboardingDoneScreen() {
|
|||||||
Codex CLI
|
Codex CLI
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-neutral-600 text-sm">
|
<Text className="text-neutral-600 text-sm">
|
||||||
{codexConfigured ? "Connected" : "Not configured"}
|
{codexConfigured ? "Auth imported" : "Not configured"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -105,7 +105,7 @@ export default function OnboardingDoneScreen() {
|
|||||||
Claude.ai
|
Claude.ai
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-neutral-600 text-sm">
|
<Text className="text-neutral-600 text-sm">
|
||||||
{claudeConfigured ? "Connected" : "Not configured"}
|
{claudeConfigured ? "Session saved" : "Not configured"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const ERROR_MESSAGES: Record<string, string> = {
|
|||||||
TOKEN_EXPIRED: "Your session token has expired. Please reconfigure.",
|
TOKEN_EXPIRED: "Your session token has expired. Please reconfigure.",
|
||||||
MISSING_ACCESS_TOKEN: "auth.json is missing the accessToken field.",
|
MISSING_ACCESS_TOKEN: "auth.json is missing the accessToken field.",
|
||||||
INVALID_JSON: "The selected file is not valid JSON.",
|
INVALID_JSON: "The selected file is not valid JSON.",
|
||||||
|
DOCUMENT_NOT_READABLE:
|
||||||
|
"The selected file could not be read. Try copying auth.json into Files or Downloads and import it again.",
|
||||||
NO_ORGS_FOUND: "No Claude organizations found for this session key.",
|
NO_ORGS_FOUND: "No Claude organizations found for this session key.",
|
||||||
INVALID_CODEX_RESPONSE:
|
INVALID_CODEX_RESPONSE:
|
||||||
"Codex returned an unexpected response. The app was kept safe from invalid data.",
|
"Codex returned an unexpected response. The app was kept safe from invalid data.",
|
||||||
|
|||||||
+1
-12
@@ -19,10 +19,7 @@ type Status = "idle" | "loading" | "success" | "error";
|
|||||||
|
|
||||||
function shouldRetry(error: unknown): boolean {
|
function shouldRetry(error: unknown): boolean {
|
||||||
const message = error instanceof Error ? error.message : "";
|
const message = error instanceof Error ? error.message : "";
|
||||||
return (
|
return message !== "TOKEN_EXPIRED" && message !== "INVALID_CODEX_RESPONSE";
|
||||||
message !== "TOKEN_EXPIRED" &&
|
|
||||||
message !== "INVALID_CODEX_RESPONSE"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wait(ms: number): Promise<void> {
|
function wait(ms: number): Promise<void> {
|
||||||
@@ -82,14 +79,6 @@ export function useCodexUsage() {
|
|||||||
} catch (caught: unknown) {
|
} catch (caught: unknown) {
|
||||||
if (version !== requestVersion.current) return;
|
if (version !== requestVersion.current) return;
|
||||||
const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR";
|
const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR";
|
||||||
if (message === "TOKEN_EXPIRED") {
|
|
||||||
await clearCodexAuth();
|
|
||||||
if (mounted.current) {
|
|
||||||
setAuth(null);
|
|
||||||
setUsage(null);
|
|
||||||
setLastFetchedAt(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mounted.current) {
|
if (mounted.current) {
|
||||||
setError(message);
|
setError(message);
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
|||||||
+178
-48
@@ -1,66 +1,86 @@
|
|||||||
import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
|
import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
|
||||||
|
|
||||||
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
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 {
|
function isNumber(value: unknown): value is number {
|
||||||
return typeof value === "number" && Number.isFinite(value);
|
return typeof value === "number" && Number.isFinite(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCodexUsageResponse(data: unknown): CodexUsageResponse {
|
function formatDurationUntil(isoString: string | null | undefined): string | null {
|
||||||
if (!data || typeof data !== "object") {
|
if (!isoString) return null;
|
||||||
throw new Error("INVALID_CODEX_RESPONSE");
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = data as Record<string, unknown>;
|
const targetMs = new Date(isoString).getTime();
|
||||||
const rateLimit = value.rate_limit as Record<string, unknown> | undefined;
|
if (Number.isNaN(targetMs)) return null;
|
||||||
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 validWindow = (window: Record<string, unknown> | undefined) =>
|
let remainingSeconds = Math.max(Math.round((targetMs - Date.now()) / 1000), 0);
|
||||||
!!window &&
|
const days = Math.floor(remainingSeconds / 86400);
|
||||||
isNumber(window.used_percent) &&
|
remainingSeconds -= days * 86400;
|
||||||
isNumber(window.reset_at) &&
|
const hours = Math.floor(remainingSeconds / 3600);
|
||||||
isNumber(window.limit_window_seconds);
|
remainingSeconds -= hours * 3600;
|
||||||
|
const minutes = Math.floor(remainingSeconds / 60);
|
||||||
|
remainingSeconds -= minutes * 60;
|
||||||
|
|
||||||
if (
|
const parts: string[] = [];
|
||||||
typeof value.plan_type !== "string" ||
|
if (days > 0) parts.push(`${days}d`);
|
||||||
!validWindow(primary) ||
|
if (hours > 0) parts.push(`${hours}h`);
|
||||||
!validWindow(secondary) ||
|
if (minutes > 0) parts.push(`${minutes}m`);
|
||||||
!credits ||
|
if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`);
|
||||||
typeof credits.has_credits !== "boolean" ||
|
return parts.join(" ");
|
||||||
typeof credits.unlimited !== "boolean" ||
|
}
|
||||||
!isNumber(credits.balance)
|
|
||||||
) {
|
|
||||||
throw new Error("INVALID_CODEX_RESPONSE");
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryWindow = primary as Record<string, unknown>;
|
function normalizeWindow(window: RawUsageWindow | null | undefined) {
|
||||||
const secondaryWindow = secondary as Record<string, unknown>;
|
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 {
|
return {
|
||||||
plan_type: value.plan_type,
|
usedPercent: typeof window.used_percent === "number" ? window.used_percent : 0,
|
||||||
rate_limit: {
|
resetsAt,
|
||||||
primary_window: {
|
windowMinutes: Math.round(windowSeconds / 60),
|
||||||
used_percent: primaryWindow.used_percent as number,
|
windowSeconds,
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageResponse> {
|
function buildBaseHeaders(auth: CodexAuth): Record<string, string> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Authorization: `Bearer ${auth.accessToken}`,
|
Authorization: `Bearer ${auth.accessToken}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -70,6 +90,103 @@ export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageRespon
|
|||||||
headers["ChatGPT-Account-Id"] = 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 });
|
const response = await fetch(USAGE_URL, { headers });
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
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}`);
|
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: unknown = await response.json();
|
const payload = (await response.json()) as RawUsageResponse;
|
||||||
return parseCodexUsageResponse(data);
|
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
@@ -1,6 +1,41 @@
|
|||||||
import * as DocumentPicker from "expo-document-picker";
|
import * as DocumentPicker from "expo-document-picker";
|
||||||
|
import * as LegacyFileSystem from "expo-file-system/legacy";
|
||||||
import type { CodexAuth } from "@/types/codex";
|
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> {
|
export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
|
||||||
const result = await DocumentPicker.getDocumentAsync({
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
type: "application/json",
|
type: "application/json",
|
||||||
@@ -9,9 +44,8 @@ export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
|
|||||||
|
|
||||||
if (result.canceled) throw new Error("PICKER_CANCELLED");
|
if (result.canceled) throw new Error("PICKER_CANCELLED");
|
||||||
|
|
||||||
const { uri } = result.assets[0];
|
const { uri, name } = result.assets[0];
|
||||||
const response = await fetch(uri);
|
const text = await readPickedTextFile(uri, name);
|
||||||
const text = await response.text();
|
|
||||||
|
|
||||||
let parsed: Record<string, unknown>;
|
let parsed: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
@@ -22,7 +56,11 @@ export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
|
|||||||
|
|
||||||
const tokens = parsed["tokens"] as Record<string, unknown> | undefined;
|
const tokens = parsed["tokens"] as Record<string, unknown> | undefined;
|
||||||
const accessToken = (tokens?.["access_token"] ?? parsed["accessToken"]) as string | 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") {
|
if (!accessToken || typeof accessToken !== "string") {
|
||||||
throw new Error("MISSING_ACCESS_TOKEN");
|
throw new Error("MISSING_ACCESS_TOKEN");
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export async function scheduleDailyDigest(
|
|||||||
}
|
}
|
||||||
if (codexUsage) {
|
if (codexUsage) {
|
||||||
parts.push(
|
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) {
|
if (codexUsage) {
|
||||||
const remaining = 100 - codexUsage.rate_limit.secondary_window.used_percent;
|
const remaining = 100 - (codexUsage.secondary?.usedPercent ?? 0);
|
||||||
if (remaining <= thresholdPct) {
|
if (remaining <= thresholdPct) {
|
||||||
alerts.push({ service: "Codex", window: "weekly", remaining });
|
alerts.push({ service: "Codex", window: "weekly", remaining });
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+408
-377
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -6,14 +6,14 @@
|
|||||||
"@expo/metro-runtime": "~56.0.15",
|
"@expo/metro-runtime": "~56.0.15",
|
||||||
"@expo/vector-icons": "^15.1.1",
|
"@expo/vector-icons": "^15.1.1",
|
||||||
"babel-preset-expo": "~56.0.0",
|
"babel-preset-expo": "~56.0.0",
|
||||||
"expo": "~56.0.12",
|
"expo": "~56.0.14",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.20",
|
||||||
"expo-document-picker": "~56.0.4",
|
"expo-document-picker": "~56.0.4",
|
||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-font": "~56.0.7",
|
"expo-font": "~56.0.7",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.15",
|
||||||
"expo-notifications": "~56.0.18",
|
"expo-notifications": "~56.0.19",
|
||||||
"expo-router": "~56.2.11",
|
"expo-router": "~56.2.13",
|
||||||
"expo-secure-store": "~56.0.4",
|
"expo-secure-store": "~56.0.4",
|
||||||
"expo-splash-screen": "~56.0.10",
|
"expo-splash-screen": "~56.0.10",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
|
|||||||
+49
-15
@@ -3,21 +3,55 @@ export interface CodexAuth {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageWindow {
|
export interface CodexUsageWindow {
|
||||||
used_percent: number;
|
usedPercent: number;
|
||||||
reset_at: number;
|
resetsAt: number;
|
||||||
limit_window_seconds: number;
|
windowMinutes: number;
|
||||||
|
windowSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodexUsageResponse {
|
export interface CodexCreditsSummary {
|
||||||
plan_type: string;
|
hasCredits: boolean;
|
||||||
rate_limit: {
|
unlimited: boolean;
|
||||||
primary_window: UsageWindow;
|
balance: number;
|
||||||
secondary_window: UsageWindow;
|
}
|
||||||
};
|
|
||||||
credits: {
|
export interface CodexResetCredit {
|
||||||
has_credits: boolean;
|
index: number;
|
||||||
unlimited: boolean;
|
status?: string;
|
||||||
balance: number;
|
grantedAt?: string | null;
|
||||||
};
|
grantedAtLocal?: string | null;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
expiresAtLocal?: string | null;
|
||||||
|
timeUntilExpiry?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexResetCoupons {
|
||||||
|
source: "live_api" | "local_state_fallback" | "unavailable";
|
||||||
|
sourceDescription: string;
|
||||||
|
availableCount?: number | null;
|
||||||
|
totalEarnedCount?: number | null;
|
||||||
|
credits?: CodexResetCredit[];
|
||||||
|
nextExpiringCredit?: CodexResetCredit | null;
|
||||||
|
dismissedAtLocal?: string | null;
|
||||||
|
latestPossibleExpiryLocal?: string | null;
|
||||||
|
latestPossibleExpiryNote?: string | null;
|
||||||
|
fallbackReason?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexDesktopSnapshot {
|
||||||
|
sessionFile?: string;
|
||||||
|
threadId?: string | null;
|
||||||
|
snapshotTimestamp?: string | null;
|
||||||
|
limitId?: string | null;
|
||||||
|
planType?: string | null;
|
||||||
|
rateLimitReachedType?: string | null;
|
||||||
|
primary: CodexUsageWindow | null;
|
||||||
|
secondary: CodexUsageWindow | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexUsageResponse extends CodexDesktopSnapshot {
|
||||||
|
credits: CodexCreditsSummary;
|
||||||
|
resetCoupons?: CodexResetCoupons | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user