Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc7e497388 | |||
| 5a00822690 |
+94
-8
@@ -15,6 +15,44 @@ import { LastUpdated } from "@/components/LastUpdated";
|
||||
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
||||
import { COLORS } from "@/lib/constants";
|
||||
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() {
|
||||
const {
|
||||
@@ -28,6 +66,9 @@ function CodexTabContent() {
|
||||
clearAuth,
|
||||
} =
|
||||
useCodexUsage();
|
||||
const resetCoupons = usage?.resetCoupons;
|
||||
const availableResetCredits =
|
||||
resetCoupons?.credits?.filter((credit) => credit.status === "available") ?? [];
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
@@ -134,21 +175,25 @@ function CodexTabContent() {
|
||||
<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">
|
||||
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
||||
{usage.plan_type}
|
||||
{usage.planType ?? "codex"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{usage.primary && (
|
||||
<UsageStat
|
||||
label={`Primary (${windowLabel(usage.rate_limit.primary_window.limit_window_seconds)})`}
|
||||
percent={usage.rate_limit.primary_window.used_percent}
|
||||
resetAtSeconds={usage.rate_limit.primary_window.reset_at}
|
||||
label={`Primary (${windowLabel(usage.primary.windowSeconds)})`}
|
||||
percent={usage.primary.usedPercent}
|
||||
resetAtSeconds={usage.primary.resetsAt}
|
||||
/>
|
||||
)}
|
||||
{usage.secondary && (
|
||||
<UsageStat
|
||||
label={`Secondary (${windowLabel(usage.rate_limit.secondary_window.limit_window_seconds)})`}
|
||||
percent={usage.rate_limit.secondary_window.used_percent}
|
||||
resetAtSeconds={usage.rate_limit.secondary_window.reset_at}
|
||||
label={`Secondary (${windowLabel(usage.secondary.windowSeconds)})`}
|
||||
percent={usage.secondary.usedPercent}
|
||||
resetAtSeconds={usage.secondary.resetsAt}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
@@ -162,7 +207,7 @@ function CodexTabContent() {
|
||||
Unlimited
|
||||
</Text>
|
||||
</View>
|
||||
) : usage.credits.has_credits ? (
|
||||
) : usage.credits.hasCredits ? (
|
||||
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
${Number(usage.credits.balance).toFixed(2)} remaining
|
||||
</Text>
|
||||
@@ -170,6 +215,47 @@ function CodexTabContent() {
|
||||
<Text className="text-sm text-neutral-400">No credits</Text>
|
||||
)}
|
||||
</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">
|
||||
<LastUpdated timestamp={lastFetchedAt} />
|
||||
</View>
|
||||
|
||||
+33
-26
@@ -16,6 +16,7 @@ import { onUsageDataLoaded } from "@/lib/notifications";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
import { ResetCountdown } from "@/components/ResetCountdown";
|
||||
import { LastUpdated } from "@/components/LastUpdated";
|
||||
import { ErrorMessage } from "@/components/ErrorMessage";
|
||||
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
||||
import { COLORS, getUsageColor } from "@/lib/constants";
|
||||
import { windowLabel } from "@/lib/timeUtils";
|
||||
@@ -158,17 +159,17 @@ function DashboardTabContent() {
|
||||
</Text>
|
||||
</View>
|
||||
<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">
|
||||
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
||||
{codex.usage.plan_type}
|
||||
{codex.usage.planType}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{codex.auth && codex.status === "error" && (
|
||||
<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>
|
||||
@@ -184,31 +185,26 @@ function DashboardTabContent() {
|
||||
/>
|
||||
)}
|
||||
{codex.auth && codex.status === "error" && (
|
||||
<View className="flex-row items-center gap-x-2 py-1">
|
||||
<MaterialIcons name="error-outline" size={16} color="#ef4444" />
|
||||
<Text className="text-sm text-red-500 dark:text-red-400">
|
||||
Failed to fetch usage
|
||||
</Text>
|
||||
</View>
|
||||
<ErrorMessage message={codex.error} />
|
||||
)}
|
||||
{codex.usage && (
|
||||
<View>
|
||||
{codex.usage.primary && (
|
||||
<UsageRow
|
||||
label={`${windowLabel(codex.usage.rate_limit.primary_window.limit_window_seconds)} window`}
|
||||
percent={codex.usage.rate_limit.primary_window.used_percent}
|
||||
resetAtSeconds={codex.usage.rate_limit.primary_window.reset_at}
|
||||
label={`${windowLabel(codex.usage.primary.windowSeconds)} window`}
|
||||
percent={codex.usage.primary.usedPercent}
|
||||
resetAtSeconds={codex.usage.primary.resetsAt}
|
||||
/>
|
||||
)}
|
||||
{codex.usage.secondary && (
|
||||
<UsageRow
|
||||
label={`${windowLabel(codex.usage.rate_limit.secondary_window.limit_window_seconds)} window`}
|
||||
percent={
|
||||
codex.usage.rate_limit.secondary_window.used_percent
|
||||
}
|
||||
resetAtSeconds={
|
||||
codex.usage.rate_limit.secondary_window.reset_at
|
||||
}
|
||||
label={`${windowLabel(codex.usage.secondary.windowSeconds)} window`}
|
||||
percent={codex.usage.secondary.usedPercent}
|
||||
resetAtSeconds={codex.usage.secondary.resetsAt}
|
||||
/>
|
||||
)}
|
||||
{!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">
|
||||
<MaterialIcons name="toll" size={14} color="#a3a3a3" />
|
||||
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
@@ -230,6 +226,22 @@ function DashboardTabContent() {
|
||||
</View>
|
||||
)}
|
||||
<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} />
|
||||
</View>
|
||||
</View>
|
||||
@@ -271,12 +283,7 @@ function DashboardTabContent() {
|
||||
/>
|
||||
)}
|
||||
{claude.auth && claude.status === "error" && (
|
||||
<View className="flex-row items-center gap-x-2 py-1">
|
||||
<MaterialIcons name="error-outline" size={16} color="#ef4444" />
|
||||
<Text className="text-sm text-red-500 dark:text-red-400">
|
||||
Failed to fetch usage
|
||||
</Text>
|
||||
</View>
|
||||
<ErrorMessage message={claude.error} />
|
||||
)}
|
||||
{claude.usage && (
|
||||
<View>
|
||||
|
||||
@@ -35,6 +35,12 @@ import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
|
||||
import type { CodexAuth } from "@/types/codex";
|
||||
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({
|
||||
icon,
|
||||
label,
|
||||
@@ -160,7 +166,7 @@ function SettingsTabContent() {
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||
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 { 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() {
|
||||
const [auth, setAuth] = useState<CodexAuth | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -28,7 +35,7 @@ export default function OnboardingCodexScreen() {
|
||||
setAuth(parsed);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||
if (msg !== "PICKER_CANCELLED") setError(msg);
|
||||
if (msg !== "PICKER_CANCELLED") setError(humanizeImportError(msg));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -86,7 +93,7 @@ export default function OnboardingCodexScreen() {
|
||||
<MaterialIcons name="check-circle" size={22} color={COLORS.codex} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white font-semibold">Connected</Text>
|
||||
<Text className="text-white font-semibold">Imported</Text>
|
||||
{auth.accountId && (
|
||||
<Text className="text-neutral-500 text-sm font-mono">
|
||||
{auth.accountId.slice(0, 12)}…
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function OnboardingDoneScreen() {
|
||||
Codex CLI
|
||||
</Text>
|
||||
<Text className="text-neutral-600 text-sm">
|
||||
{codexConfigured ? "Connected" : "Not configured"}
|
||||
{codexConfigured ? "Auth imported" : "Not configured"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -105,7 +105,7 @@ export default function OnboardingDoneScreen() {
|
||||
Claude.ai
|
||||
</Text>
|
||||
<Text className="text-neutral-600 text-sm">
|
||||
{claudeConfigured ? "Connected" : "Not configured"}
|
||||
{claudeConfigured ? "Session saved" : "Not configured"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -4,6 +4,8 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
TOKEN_EXPIRED: "Your session token has expired. Please reconfigure.",
|
||||
MISSING_ACCESS_TOKEN: "auth.json is missing the accessToken field.",
|
||||
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.",
|
||||
INVALID_CODEX_RESPONSE:
|
||||
"Codex returned an unexpected response. The app was kept safe from invalid data.",
|
||||
@@ -11,11 +13,23 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
"Claude returned an unexpected usage response. The app was kept safe from invalid data.",
|
||||
INVALID_CLAUDE_ORGS_RESPONSE:
|
||||
"Claude returned an unexpected organizations response.",
|
||||
UNKNOWN_ERROR: "An unknown error occurred. Try refreshing.",
|
||||
};
|
||||
|
||||
function humanize(code: string): string {
|
||||
if (code.startsWith("HTTP_ERROR_")) {
|
||||
return `Unexpected server error (HTTP ${code.replace("HTTP_ERROR_", "")}).`;
|
||||
const status = code.replace("HTTP_ERROR_", "");
|
||||
if (status === "429") return "Rate limited by the server. Try again shortly.";
|
||||
if (status === "500" || status === "502" || status === "503")
|
||||
return `Server error (${status}). The service may be down.`;
|
||||
return `Unexpected server response (HTTP ${status}).`;
|
||||
}
|
||||
if (
|
||||
code === "Network request failed" ||
|
||||
code.includes("fetch") ||
|
||||
code.includes("network")
|
||||
) {
|
||||
return "Network error. Check your internet connection and try again.";
|
||||
}
|
||||
return ERROR_MESSAGES[code] ?? `Unexpected error: ${code}`;
|
||||
}
|
||||
|
||||
+1
-12
@@ -19,10 +19,7 @@ type Status = "idle" | "loading" | "success" | "error";
|
||||
|
||||
function shouldRetry(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
return (
|
||||
message !== "TOKEN_EXPIRED" &&
|
||||
message !== "INVALID_CODEX_RESPONSE"
|
||||
);
|
||||
return message !== "TOKEN_EXPIRED" && message !== "INVALID_CODEX_RESPONSE";
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
@@ -82,14 +79,6 @@ export function useCodexUsage() {
|
||||
} catch (caught: unknown) {
|
||||
if (version !== requestVersion.current) return;
|
||||
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) {
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
|
||||
+179
-49
@@ -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 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(" ");
|
||||
}
|
||||
|
||||
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;
|
||||
function normalizeWindow(window: RawUsageWindow | null | undefined) {
|
||||
if (!window) return null;
|
||||
|
||||
const validWindow = (window: Record<string, unknown> | undefined) =>
|
||||
!!window &&
|
||||
isNumber(window.used_percent) &&
|
||||
isNumber(window.reset_at) &&
|
||||
isNumber(window.limit_window_seconds);
|
||||
|
||||
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 primaryWindow = primary as Record<string, unknown>;
|
||||
const secondaryWindow = secondary as Record<string, unknown>;
|
||||
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
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
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/vector-icons": "^15.1.1",
|
||||
"babel-preset-expo": "~56.0.0",
|
||||
"expo": "~56.0.12",
|
||||
"expo-constants": "~56.0.18",
|
||||
"expo": "~56.0.14",
|
||||
"expo-constants": "~56.0.20",
|
||||
"expo-document-picker": "~56.0.4",
|
||||
"expo-file-system": "~56.0.8",
|
||||
"expo-font": "~56.0.7",
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-notifications": "~56.0.18",
|
||||
"expo-router": "~56.2.11",
|
||||
"expo-linking": "~56.0.15",
|
||||
"expo-notifications": "~56.0.19",
|
||||
"expo-router": "~56.2.13",
|
||||
"expo-secure-store": "~56.0.4",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
|
||||
+47
-13
@@ -3,21 +3,55 @@ export interface CodexAuth {
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export interface UsageWindow {
|
||||
used_percent: number;
|
||||
reset_at: number;
|
||||
limit_window_seconds: number;
|
||||
export interface CodexUsageWindow {
|
||||
usedPercent: number;
|
||||
resetsAt: number;
|
||||
windowMinutes: number;
|
||||
windowSeconds: number;
|
||||
}
|
||||
|
||||
export interface CodexUsageResponse {
|
||||
plan_type: string;
|
||||
rate_limit: {
|
||||
primary_window: UsageWindow;
|
||||
secondary_window: UsageWindow;
|
||||
};
|
||||
credits: {
|
||||
has_credits: boolean;
|
||||
export interface CodexCreditsSummary {
|
||||
hasCredits: boolean;
|
||||
unlimited: boolean;
|
||||
balance: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CodexResetCredit {
|
||||
index: number;
|
||||
status?: string;
|
||||
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