2 Commits

Author SHA1 Message Date
Space-Banane dc7e497388 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
2026-07-04 18:59:56 +02:00
space 5a00822690 fix: show meaningful error messages in dashboard
CodexBar Mobile Build / build-android (push) Successful in 22m12s
CodexBar Mobile Build / release (push) Successful in 17s
Replace hardcoded "Failed to fetch usage" in the Dashboard tab with the
ErrorMessage component so errors like TOKEN_EXPIRED, HTTP 429, and network
failures display human-readable text consistent with the detail tabs.
Also improve HTTP error copy and add network-level error detection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:28:44 +02:00
13 changed files with 853 additions and 511 deletions
+98 -12
View File
@@ -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>
+37 -30
View File
@@ -16,6 +16,7 @@ import { onUsageDataLoaded } from "@/lib/notifications";
import { ProgressBar } from "@/components/ProgressBar"; import { ProgressBar } from "@/components/ProgressBar";
import { ResetCountdown } from "@/components/ResetCountdown"; import { ResetCountdown } from "@/components/ResetCountdown";
import { LastUpdated } from "@/components/LastUpdated"; import { LastUpdated } from "@/components/LastUpdated";
import { ErrorMessage } from "@/components/ErrorMessage";
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary"; import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
import { COLORS, getUsageColor } from "@/lib/constants"; import { COLORS, getUsageColor } from "@/lib/constants";
import { windowLabel } from "@/lib/timeUtils"; import { windowLabel } from "@/lib/timeUtils";
@@ -158,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>
@@ -184,31 +185,26 @@ function DashboardTabContent() {
/> />
)} )}
{codex.auth && codex.status === "error" && ( {codex.auth && codex.status === "error" && (
<View className="flex-row items-center gap-x-2 py-1"> <ErrorMessage message={codex.error} />
<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>
)} )}
{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">
@@ -230,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>
@@ -271,12 +283,7 @@ function DashboardTabContent() {
/> />
)} )}
{claude.auth && claude.status === "error" && ( {claude.auth && claude.status === "error" && (
<View className="flex-row items-center gap-x-2 py-1"> <ErrorMessage message={claude.error} />
<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>
)} )}
{claude.usage && ( {claude.usage && (
<View> <View>
+7 -1
View File
@@ -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));
} }
} }
}; };
+9 -2
View File
@@ -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)}
+2 -2
View File
@@ -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>
+15 -1
View File
@@ -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.",
@@ -11,11 +13,23 @@ const ERROR_MESSAGES: Record<string, string> = {
"Claude returned an unexpected usage response. The app was kept safe from invalid data.", "Claude returned an unexpected usage response. The app was kept safe from invalid data.",
INVALID_CLAUDE_ORGS_RESPONSE: INVALID_CLAUDE_ORGS_RESPONSE:
"Claude returned an unexpected organizations response.", "Claude returned an unexpected organizations response.",
UNKNOWN_ERROR: "An unknown error occurred. Try refreshing.",
}; };
function humanize(code: string): string { function humanize(code: string): string {
if (code.startsWith("HTTP_ERROR_")) { 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}`; return ERROR_MESSAGES[code] ?? `Unexpected error: ${code}`;
} }
+1 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+2 -2
View File
@@ -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 });
} }
+408 -377
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -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
View File
@@ -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;
} }