feat: show live Codex usage status in dashboard
Store Codex wham/usage live plan and rate-limit state during account refresh, surface safe dashboard badges, and keep JWT subscription expiry as stale fallback only. Redacts balance and spend limit amounts from API/UI exposure.
This commit is contained in:
@@ -20,6 +20,12 @@ import {
|
|||||||
updateFastMode,
|
updateFastMode,
|
||||||
updateModels,
|
updateModels,
|
||||||
} from './api';
|
} from './api';
|
||||||
|
import {
|
||||||
|
formatDateTime,
|
||||||
|
formatJwtCacheExpiry,
|
||||||
|
formatLiveStatusBadge,
|
||||||
|
formatUsageBadge,
|
||||||
|
} from './codexAccountBadges';
|
||||||
import { type Locale, type Messages } from './i18n';
|
import { type Locale, type Messages } from './i18n';
|
||||||
import { MoaSettingsPanel } from './MoaSettingsPanel';
|
import { MoaSettingsPanel } from './MoaSettingsPanel';
|
||||||
import { RuntimeInventorySettings } from './RuntimeInventorySettings';
|
import { RuntimeInventorySettings } from './RuntimeInventorySettings';
|
||||||
@@ -410,37 +416,6 @@ function CodexFeatureSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatExpiry(
|
|
||||||
iso: string | null,
|
|
||||||
): { label: string; cls: string } | null {
|
|
||||||
if (!iso) return null;
|
|
||||||
const dt = new Date(iso);
|
|
||||||
if (Number.isNaN(dt.getTime())) return null;
|
|
||||||
const days = (dt.getTime() - Date.now()) / 86400000;
|
|
||||||
const dateStr = dt.toLocaleDateString('ko-KR', {
|
|
||||||
year: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
});
|
|
||||||
if (days < 0) {
|
|
||||||
const ago = Math.ceil(-days);
|
|
||||||
return {
|
|
||||||
label: `결제 만료 ${dateStr} (${ago}일 전)`,
|
|
||||||
cls: 'is-expired',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (days < 7) {
|
|
||||||
return {
|
|
||||||
label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`,
|
|
||||||
cls: 'is-soon',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`,
|
|
||||||
cls: 'is-active',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccountSettings() {
|
function AccountSettings() {
|
||||||
const [data, setData] = useState<AccountData | null>(null);
|
const [data, setData] = useState<AccountData | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -702,8 +677,9 @@ function CodexAccounts({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<p className="settings-hint">
|
<p className="settings-hint">
|
||||||
OAuth 토큰은 6시간마다 자동 갱신됩니다. plan 변경/해지가 즉시 반영되게
|
“갱신”은 chatgpt.com wham/usage live plan·rate limit·지출 제한을
|
||||||
하려면 수동으로 “전체 갱신”을 누르세요.
|
저장합니다. JWT 만료일 캐시는 live 값이 없을 때만 fallback으로
|
||||||
|
표시합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -726,7 +702,14 @@ function CodexAccountRow({
|
|||||||
onSwitch: (index: number) => void;
|
onSwitch: (index: number) => void;
|
||||||
perRowBusy: string | null;
|
perRowBusy: string | null;
|
||||||
}) {
|
}) {
|
||||||
const expiry = formatExpiry(acc.subscriptionUntil);
|
const liveBadge = formatLiveStatusBadge(acc.liveStatus);
|
||||||
|
const usageBadge = formatUsageBadge(acc.liveStatus);
|
||||||
|
const cachedExpiry = acc.liveStatus
|
||||||
|
? null
|
||||||
|
: formatJwtCacheExpiry(acc.subscriptionUntil);
|
||||||
|
const checkedAt = acc.subscriptionLastChecked
|
||||||
|
? formatDateTime(acc.subscriptionLastChecked)
|
||||||
|
: null;
|
||||||
const refreshing = perRowBusy === `refresh:${acc.index}`;
|
const refreshing = perRowBusy === `refresh:${acc.index}`;
|
||||||
const switching = perRowBusy === `switch:${acc.index}`;
|
const switching = perRowBusy === `switch:${acc.index}`;
|
||||||
|
|
||||||
@@ -746,16 +729,39 @@ function CodexAccountRow({
|
|||||||
<span className="settings-account-plan">
|
<span className="settings-account-plan">
|
||||||
{acc.planType ?? 'unknown'}
|
{acc.planType ?? 'unknown'}
|
||||||
</span>
|
</span>
|
||||||
{expiry ? (
|
{liveBadge ? (
|
||||||
<span
|
<span
|
||||||
className={`settings-account-badge ${expiry.cls}`}
|
className={`settings-account-badge ${liveBadge.cls}`}
|
||||||
|
title={liveBadge.title}
|
||||||
|
>
|
||||||
|
{liveBadge.label}
|
||||||
|
</span>
|
||||||
|
) : cachedExpiry ? (
|
||||||
|
<span
|
||||||
|
className={`settings-account-badge ${cachedExpiry.cls}`}
|
||||||
|
title={cachedExpiry.title}
|
||||||
|
>
|
||||||
|
{cachedExpiry.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{usageBadge ? (
|
||||||
|
<span
|
||||||
|
className="settings-account-badge is-muted"
|
||||||
|
title={usageBadge.title}
|
||||||
|
>
|
||||||
|
{usageBadge.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{checkedAt ? (
|
||||||
|
<span
|
||||||
|
className="settings-account-badge is-muted"
|
||||||
title={
|
title={
|
||||||
acc.subscriptionLastChecked
|
acc.liveStatus
|
||||||
? `last checked: ${acc.subscriptionLastChecked.slice(0, 10)}`
|
? 'wham/usage live checked_at'
|
||||||
: undefined
|
: 'JWT subscription_last_checked cache'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{expiry.label}
|
갱신 {checkedAt}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -764,7 +770,7 @@ function CodexAccountRow({
|
|||||||
className="settings-secondary"
|
className="settings-secondary"
|
||||||
disabled={busy || perRowBusy !== null}
|
disabled={busy || perRowBusy !== null}
|
||||||
onClick={() => onRefresh(acc.index)}
|
onClick={() => onRefresh(acc.index)}
|
||||||
title="OAuth 토큰을 다시 받아 구독 상태를 갱신합니다"
|
title="OAuth 토큰을 다시 받고 wham/usage live plan·limit 상태를 갱신합니다"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{refreshing ? '갱신중…' : '갱신'}
|
{refreshing ? '갱신중…' : '갱신'}
|
||||||
|
|||||||
@@ -362,6 +362,48 @@ export interface ClaudeAccountSummary {
|
|||||||
exists: boolean;
|
exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CodexRateLimitWindowSummary {
|
||||||
|
limitWindowSeconds: number | null;
|
||||||
|
resetAfterSeconds: number | null;
|
||||||
|
resetAt: string | null;
|
||||||
|
usedPercent: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexRateLimitSummary {
|
||||||
|
allowed: boolean | null;
|
||||||
|
limitReached: boolean | null;
|
||||||
|
primaryWindow: CodexRateLimitWindowSummary | null;
|
||||||
|
secondaryWindow: CodexRateLimitWindowSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexAdditionalRateLimitSummary {
|
||||||
|
limitName: string | null;
|
||||||
|
meteredFeature: string | null;
|
||||||
|
rateLimit: CodexRateLimitSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexCreditsSummary {
|
||||||
|
hasCredits: boolean | null;
|
||||||
|
overageLimitReached: boolean | null;
|
||||||
|
unlimited: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexSpendControlSummary {
|
||||||
|
reached: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexLiveStatusSummary {
|
||||||
|
checkedAt: string;
|
||||||
|
source: 'wham/usage';
|
||||||
|
planType: string | null;
|
||||||
|
email: string | null;
|
||||||
|
rateLimit: CodexRateLimitSummary | null;
|
||||||
|
rateLimitReachedType: string | null;
|
||||||
|
additionalRateLimits: CodexAdditionalRateLimitSummary[];
|
||||||
|
credits: CodexCreditsSummary | null;
|
||||||
|
spendControl: CodexSpendControlSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CodexAccountSummary {
|
export interface CodexAccountSummary {
|
||||||
index: number;
|
index: number;
|
||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
@@ -369,6 +411,8 @@ export interface CodexAccountSummary {
|
|||||||
planType: string | null;
|
planType: string | null;
|
||||||
subscriptionUntil: string | null;
|
subscriptionUntil: string | null;
|
||||||
subscriptionLastChecked: string | null;
|
subscriptionLastChecked: string | null;
|
||||||
|
subscriptionSource: 'jwt-cache' | null;
|
||||||
|
liveStatus: CodexLiveStatusSummary | null;
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
120
apps/dashboard/src/codexAccountBadges.ts
Normal file
120
apps/dashboard/src/codexAccountBadges.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
type CodexLiveStatusSummary,
|
||||||
|
type CodexRateLimitSummary,
|
||||||
|
type CodexRateLimitWindowSummary,
|
||||||
|
} from './api';
|
||||||
|
|
||||||
|
export function formatDateTime(iso: string): string | null {
|
||||||
|
const dt = new Date(iso);
|
||||||
|
if (Number.isNaN(dt.getTime())) return null;
|
||||||
|
return dt.toLocaleString('ko-KR', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatJwtCacheExpiry(
|
||||||
|
iso: string | null,
|
||||||
|
): { label: string; cls: string; title: string } | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const dt = new Date(iso);
|
||||||
|
if (Number.isNaN(dt.getTime())) return null;
|
||||||
|
const days = (dt.getTime() - Date.now()) / 86400000;
|
||||||
|
const dateStr = dt.toLocaleDateString('ko-KR', {
|
||||||
|
year: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
const suffix =
|
||||||
|
days < 0 ? `${Math.ceil(-days)}일 전` : `${Math.floor(days)}일 남음`;
|
||||||
|
return {
|
||||||
|
label: `JWT 캐시 ${dateStr}`,
|
||||||
|
cls: 'is-stale',
|
||||||
|
title: `OpenAI/Auth0 JWT 캐시 만료일입니다. live wham/usage 갱신값이 있으면 이 값은 표시하지 않습니다. (${suffix})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWindowName(seconds: number | null | undefined): string {
|
||||||
|
if (!seconds || !Number.isFinite(seconds)) return 'limit';
|
||||||
|
if (seconds % 86400 === 0) return `${Math.round(seconds / 86400)}d`;
|
||||||
|
if (seconds % 3600 === 0) return `${Math.round(seconds / 3600)}h`;
|
||||||
|
if (seconds % 60 === 0) return `${Math.round(seconds / 60)}m`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number | null | undefined): string {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return '?%';
|
||||||
|
return `${Math.round(value)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRateLimitWindow(
|
||||||
|
window: CodexRateLimitWindowSummary | null,
|
||||||
|
): string | null {
|
||||||
|
if (!window) return null;
|
||||||
|
return `${formatWindowName(window.limitWindowSeconds)} ${formatPercent(window.usedPercent)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLiveStatusBadge(
|
||||||
|
live: CodexLiveStatusSummary | null,
|
||||||
|
): { label: string; cls: string; title: string } | null {
|
||||||
|
if (!live) return null;
|
||||||
|
const limits = [
|
||||||
|
live.rateLimit,
|
||||||
|
...live.additionalRateLimits.map((limit) => limit.rateLimit),
|
||||||
|
].filter((limit): limit is CodexRateLimitSummary => limit !== null);
|
||||||
|
const anyReached = limits.some((limit) => limit.limitReached === true);
|
||||||
|
const anyBlocked = limits.some((limit) => limit.allowed === false);
|
||||||
|
const checkedAt = formatDateTime(live.checkedAt) ?? live.checkedAt;
|
||||||
|
if (anyReached || anyBlocked || live.rateLimitReachedType) {
|
||||||
|
return {
|
||||||
|
label: 'live 제한 도달',
|
||||||
|
cls: 'is-expired',
|
||||||
|
title: `wham/usage live 확인: ${checkedAt}${
|
||||||
|
live.rateLimitReachedType ? ` · ${live.rateLimitReachedType}` : ''
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (live.spendControl?.reached || live.credits?.overageLimitReached) {
|
||||||
|
return {
|
||||||
|
label: 'live 지출 제한',
|
||||||
|
cls: 'is-expired',
|
||||||
|
title: `wham/usage live 확인: ${checkedAt}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (limits.some((limit) => limit.allowed === true)) {
|
||||||
|
return {
|
||||||
|
label: 'live 사용 가능',
|
||||||
|
cls: 'is-active',
|
||||||
|
title: `wham/usage live 확인: ${checkedAt}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: 'live 확인됨',
|
||||||
|
cls: 'is-soon',
|
||||||
|
title: `wham/usage live 확인: ${checkedAt}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUsageBadge(
|
||||||
|
live: CodexLiveStatusSummary | null,
|
||||||
|
): { label: string; title: string } | null {
|
||||||
|
if (!live?.rateLimit) return null;
|
||||||
|
const primary = formatRateLimitWindow(live.rateLimit.primaryWindow);
|
||||||
|
const secondary = formatRateLimitWindow(live.rateLimit.secondaryWindow);
|
||||||
|
const label = [primary, secondary].filter(Boolean).join(' · ');
|
||||||
|
if (!label) return null;
|
||||||
|
const resetParts = [
|
||||||
|
live.rateLimit.primaryWindow?.resetAt
|
||||||
|
? `primary reset ${formatDateTime(live.rateLimit.primaryWindow.resetAt) ?? live.rateLimit.primaryWindow.resetAt}`
|
||||||
|
: null,
|
||||||
|
live.rateLimit.secondaryWindow?.resetAt
|
||||||
|
? `secondary reset ${formatDateTime(live.rateLimit.secondaryWindow.resetAt) ?? live.rateLimit.secondaryWindow.resetAt}`
|
||||||
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
title: resetParts.join(' · ') || 'wham/usage live rate_limit',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1127,6 +1127,16 @@ button:disabled {
|
|||||||
color: var(--status-critical, #e0644b);
|
color: var(--status-critical, #e0644b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-account-badge.is-muted {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--muted-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-badge.is-stale {
|
||||||
|
background: rgba(148, 169, 210, 0.14);
|
||||||
|
color: var(--muted-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-account-meta {
|
.settings-account-meta {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--bg-ink);
|
color: var(--bg-ink);
|
||||||
|
|||||||
@@ -425,8 +425,30 @@ async function handleMockApi(route: Route, state: MockApiState) {
|
|||||||
accountId: 'acct_test',
|
accountId: 'acct_test',
|
||||||
email: 'codex@example.com',
|
email: 'codex@example.com',
|
||||||
planType: 'plus',
|
planType: 'plus',
|
||||||
subscriptionUntil: '2099-01-01T00:00:00.000Z',
|
subscriptionUntil: null,
|
||||||
subscriptionLastChecked: '2026-01-01T00:00:00.000Z',
|
subscriptionLastChecked: '2026-01-01T00:00:00.000Z',
|
||||||
|
subscriptionSource: null,
|
||||||
|
liveStatus: {
|
||||||
|
checkedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'wham/usage',
|
||||||
|
planType: 'plus',
|
||||||
|
email: 'codex@example.com',
|
||||||
|
rateLimit: {
|
||||||
|
allowed: true,
|
||||||
|
limitReached: false,
|
||||||
|
primaryWindow: {
|
||||||
|
limitWindowSeconds: 18000,
|
||||||
|
resetAfterSeconds: 3600,
|
||||||
|
resetAt: '2026-01-01T01:00:00.000Z',
|
||||||
|
usedPercent: 8,
|
||||||
|
},
|
||||||
|
secondaryWindow: null,
|
||||||
|
},
|
||||||
|
rateLimitReachedType: null,
|
||||||
|
additionalRateLimits: [],
|
||||||
|
credits: null,
|
||||||
|
spendControl: null,
|
||||||
|
},
|
||||||
exists: true,
|
exists: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
245
src/codex-live-status.ts
Normal file
245
src/codex-live-status.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
export interface CodexRateLimitWindowSummary {
|
||||||
|
limitWindowSeconds: number | null;
|
||||||
|
resetAfterSeconds: number | null;
|
||||||
|
resetAt: string | null;
|
||||||
|
usedPercent: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexRateLimitSummary {
|
||||||
|
allowed: boolean | null;
|
||||||
|
limitReached: boolean | null;
|
||||||
|
primaryWindow: CodexRateLimitWindowSummary | null;
|
||||||
|
secondaryWindow: CodexRateLimitWindowSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexAdditionalRateLimitSummary {
|
||||||
|
limitName: string | null;
|
||||||
|
meteredFeature: string | null;
|
||||||
|
rateLimit: CodexRateLimitSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexCreditsSummary {
|
||||||
|
hasCredits: boolean | null;
|
||||||
|
overageLimitReached: boolean | null;
|
||||||
|
unlimited: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexSpendControlSummary {
|
||||||
|
reached: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexLiveStatusSummary {
|
||||||
|
checkedAt: string;
|
||||||
|
source: 'wham/usage';
|
||||||
|
planType: string | null;
|
||||||
|
email: string | null;
|
||||||
|
rateLimit: CodexRateLimitSummary | null;
|
||||||
|
rateLimitReachedType: string | null;
|
||||||
|
additionalRateLimits: CodexAdditionalRateLimitSummary[];
|
||||||
|
credits: CodexCreditsSummary | null;
|
||||||
|
spendControl: CodexSpendControlSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexLiveRateLimitWindow {
|
||||||
|
limit_window_seconds?: number | null;
|
||||||
|
reset_after_seconds?: number | null;
|
||||||
|
reset_at?: number | null;
|
||||||
|
used_percent?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexLiveRateLimit {
|
||||||
|
allowed?: boolean | null;
|
||||||
|
limit_reached?: boolean | null;
|
||||||
|
primary_window?: CodexLiveRateLimitWindow | null;
|
||||||
|
secondary_window?: CodexLiveRateLimitWindow | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexAdditionalLiveRateLimit {
|
||||||
|
limit_name?: string | null;
|
||||||
|
metered_feature?: string | null;
|
||||||
|
rate_limit?: CodexLiveRateLimit | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexLiveCredits {
|
||||||
|
has_credits?: boolean | null;
|
||||||
|
overage_limit_reached?: boolean | null;
|
||||||
|
unlimited?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexLiveSpendControl {
|
||||||
|
reached?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexLiveStatus {
|
||||||
|
checked_at: string;
|
||||||
|
plan_type?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
rate_limit?: CodexLiveRateLimit | null;
|
||||||
|
rate_limit_reached_type?: string | null;
|
||||||
|
additional_rate_limits?: CodexAdditionalLiveRateLimit[];
|
||||||
|
credits?: CodexLiveCredits | null;
|
||||||
|
spend_control?: CodexLiveSpendControl | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordOrNull(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringOrNull(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrNull(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanOrNull(value: unknown): boolean | null {
|
||||||
|
return typeof value === 'boolean' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function epochSecondsToIso(value: number | null | undefined): string | null {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||||
|
return new Date(value * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRateLimitWindow(
|
||||||
|
value: unknown,
|
||||||
|
): CodexLiveRateLimitWindow | null {
|
||||||
|
const record = recordOrNull(value);
|
||||||
|
if (!record) return null;
|
||||||
|
return {
|
||||||
|
limit_window_seconds: numberOrNull(record.limit_window_seconds),
|
||||||
|
reset_after_seconds: numberOrNull(record.reset_after_seconds),
|
||||||
|
reset_at: numberOrNull(record.reset_at),
|
||||||
|
used_percent: numberOrNull(record.used_percent),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRateLimit(value: unknown): CodexLiveRateLimit | null {
|
||||||
|
const record = recordOrNull(value);
|
||||||
|
if (!record) return null;
|
||||||
|
return {
|
||||||
|
allowed: booleanOrNull(record.allowed),
|
||||||
|
limit_reached: booleanOrNull(record.limit_reached),
|
||||||
|
primary_window: normalizeRateLimitWindow(record.primary_window),
|
||||||
|
secondary_window: normalizeRateLimitWindow(record.secondary_window),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAdditionalRateLimits(
|
||||||
|
value: unknown,
|
||||||
|
): CodexAdditionalLiveRateLimit[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((item): CodexAdditionalLiveRateLimit | null => {
|
||||||
|
const record = recordOrNull(item);
|
||||||
|
if (!record) return null;
|
||||||
|
return {
|
||||||
|
limit_name: stringOrNull(record.limit_name),
|
||||||
|
metered_feature: stringOrNull(record.metered_feature),
|
||||||
|
rate_limit: normalizeRateLimit(record.rate_limit),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is CodexAdditionalLiveRateLimit => item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCredits(value: unknown): CodexLiveCredits | null {
|
||||||
|
const record = recordOrNull(value);
|
||||||
|
if (!record) return null;
|
||||||
|
return {
|
||||||
|
has_credits: booleanOrNull(record.has_credits),
|
||||||
|
overage_limit_reached: booleanOrNull(record.overage_limit_reached),
|
||||||
|
unlimited: booleanOrNull(record.unlimited),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSpendControl(value: unknown): CodexLiveSpendControl | null {
|
||||||
|
const record = recordOrNull(value);
|
||||||
|
if (!record) return null;
|
||||||
|
return {
|
||||||
|
reached: booleanOrNull(record.reached),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCodexLiveStatus(
|
||||||
|
value: unknown,
|
||||||
|
checkedAt: string,
|
||||||
|
): CodexLiveStatus | null {
|
||||||
|
const record = recordOrNull(value);
|
||||||
|
if (!record) return null;
|
||||||
|
return {
|
||||||
|
checked_at: checkedAt,
|
||||||
|
plan_type: stringOrNull(record.plan_type),
|
||||||
|
email: stringOrNull(record.email),
|
||||||
|
rate_limit: normalizeRateLimit(record.rate_limit),
|
||||||
|
rate_limit_reached_type: stringOrNull(record.rate_limit_reached_type),
|
||||||
|
additional_rate_limits: normalizeAdditionalRateLimits(
|
||||||
|
record.additional_rate_limits,
|
||||||
|
),
|
||||||
|
credits: normalizeCredits(record.credits),
|
||||||
|
spend_control: normalizeSpendControl(record.spend_control),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRateLimitWindowSummary(
|
||||||
|
window: CodexLiveRateLimitWindow | null | undefined,
|
||||||
|
): CodexRateLimitWindowSummary | null {
|
||||||
|
if (!window) return null;
|
||||||
|
return {
|
||||||
|
limitWindowSeconds: window.limit_window_seconds ?? null,
|
||||||
|
resetAfterSeconds: window.reset_after_seconds ?? null,
|
||||||
|
resetAt: epochSecondsToIso(window.reset_at),
|
||||||
|
usedPercent: window.used_percent ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRateLimitSummary(
|
||||||
|
rateLimit: CodexLiveRateLimit | null | undefined,
|
||||||
|
): CodexRateLimitSummary | null {
|
||||||
|
if (!rateLimit) return null;
|
||||||
|
return {
|
||||||
|
allowed: rateLimit.allowed ?? null,
|
||||||
|
limitReached: rateLimit.limit_reached ?? null,
|
||||||
|
primaryWindow: toRateLimitWindowSummary(rateLimit.primary_window),
|
||||||
|
secondaryWindow: toRateLimitWindowSummary(rateLimit.secondary_window),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCodexLiveStatusSummary(
|
||||||
|
status: CodexLiveStatus,
|
||||||
|
): CodexLiveStatusSummary {
|
||||||
|
return {
|
||||||
|
checkedAt: status.checked_at,
|
||||||
|
source: 'wham/usage',
|
||||||
|
planType: status.plan_type ?? null,
|
||||||
|
email: status.email ?? null,
|
||||||
|
rateLimit: toRateLimitSummary(status.rate_limit),
|
||||||
|
rateLimitReachedType: status.rate_limit_reached_type ?? null,
|
||||||
|
additionalRateLimits: (status.additional_rate_limits ?? []).map(
|
||||||
|
(limit) => ({
|
||||||
|
limitName: limit.limit_name ?? null,
|
||||||
|
meteredFeature: limit.metered_feature ?? null,
|
||||||
|
rateLimit: toRateLimitSummary(limit.rate_limit),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
credits: status.credits
|
||||||
|
? {
|
||||||
|
hasCredits: status.credits.has_credits ?? null,
|
||||||
|
overageLimitReached: status.credits.overage_limit_reached ?? null,
|
||||||
|
unlimited: status.credits.unlimited ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
spendControl: status.spend_control
|
||||||
|
? {
|
||||||
|
reached: status.spend_control.reached ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,33 +2,63 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { getCodexFeatures, updateCodexFeatures } from './settings-store.js';
|
import {
|
||||||
|
getCodexFeatures,
|
||||||
|
listCodexAccounts,
|
||||||
|
refreshCodexAccount,
|
||||||
|
updateCodexFeatures,
|
||||||
|
} from './settings-store.js';
|
||||||
|
|
||||||
describe('settings-store Codex features', () => {
|
describe('settings-store Codex features', () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
let previousCwd: string;
|
let previousCwd: string;
|
||||||
let previousCodexGoals: string | undefined;
|
let previousCodexGoals: string | undefined;
|
||||||
|
let previousHome: string | undefined;
|
||||||
|
let previousSettingsHome: string | undefined;
|
||||||
|
let previousFetch: typeof fetch;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-settings-'));
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-settings-'));
|
||||||
previousCwd = process.cwd();
|
previousCwd = process.cwd();
|
||||||
previousCodexGoals = process.env.CODEX_GOALS;
|
previousCodexGoals = process.env.CODEX_GOALS;
|
||||||
|
previousHome = process.env.HOME;
|
||||||
|
previousSettingsHome = process.env.EJCLAW_SETTINGS_HOME;
|
||||||
|
previousFetch = globalThis.fetch;
|
||||||
delete process.env.CODEX_GOALS;
|
delete process.env.CODEX_GOALS;
|
||||||
|
process.env.HOME = tempDir;
|
||||||
|
process.env.EJCLAW_SETTINGS_HOME = tempDir;
|
||||||
process.chdir(tempDir);
|
process.chdir(tempDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.chdir(previousCwd);
|
process.chdir(previousCwd);
|
||||||
|
globalThis.fetch = previousFetch;
|
||||||
if (previousCodexGoals === undefined) {
|
if (previousCodexGoals === undefined) {
|
||||||
delete process.env.CODEX_GOALS;
|
delete process.env.CODEX_GOALS;
|
||||||
} else {
|
} else {
|
||||||
process.env.CODEX_GOALS = previousCodexGoals;
|
process.env.CODEX_GOALS = previousCodexGoals;
|
||||||
}
|
}
|
||||||
|
if (previousHome === undefined) {
|
||||||
|
delete process.env.HOME;
|
||||||
|
} else {
|
||||||
|
process.env.HOME = previousHome;
|
||||||
|
}
|
||||||
|
if (previousSettingsHome === undefined) {
|
||||||
|
delete process.env.EJCLAW_SETTINGS_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.EJCLAW_SETTINGS_HOME = previousSettingsHome;
|
||||||
|
}
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function fakeJwt(payload: Record<string, unknown>): string {
|
||||||
|
const encode = (value: Record<string, unknown>) =>
|
||||||
|
Buffer.from(JSON.stringify(value)).toString('base64');
|
||||||
|
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
it('stores the Codex goals opt-in in the EJClaw .env file', () => {
|
it('stores the Codex goals opt-in in the EJClaw .env file', () => {
|
||||||
expect(getCodexFeatures()).toEqual({ goals: false });
|
expect(getCodexFeatures()).toEqual({ goals: false });
|
||||||
|
|
||||||
@@ -42,4 +72,123 @@ describe('settings-store Codex features', () => {
|
|||||||
'CODEX_GOALS=false',
|
'CODEX_GOALS=false',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores wham usage status and does not expose stale JWT expiry as live billing', async () => {
|
||||||
|
const accountDir = path.join(tempDir, '.codex-accounts', '1');
|
||||||
|
fs.mkdirSync(accountDir, { recursive: true });
|
||||||
|
const staleJwt = fakeJwt({
|
||||||
|
email: 'cached@example.com',
|
||||||
|
sub: 'acct_cached',
|
||||||
|
'https://api.openai.com/auth': {
|
||||||
|
chatgpt_plan_type: 'free',
|
||||||
|
chatgpt_subscription_active_until: '2026-01-01T00:00:00.000Z',
|
||||||
|
chatgpt_subscription_last_checked: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(accountDir, 'auth.json'),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
tokens: {
|
||||||
|
access_token: 'old-access',
|
||||||
|
id_token: staleJwt,
|
||||||
|
refresh_token: 'refresh-token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetAt = 1_779_300_000;
|
||||||
|
const fetchMock = vi.fn(async (input: string | URL | Request) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === 'https://auth.openai.com/oauth/token') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
access_token: 'live-access',
|
||||||
|
id_token: staleJwt,
|
||||||
|
refresh_token: 'next-refresh-token',
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url === 'https://chatgpt.com/backend-api/wham/usage') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
email: 'live@example.com',
|
||||||
|
plan_type: 'pro',
|
||||||
|
rate_limit: {
|
||||||
|
allowed: true,
|
||||||
|
limit_reached: false,
|
||||||
|
primary_window: {
|
||||||
|
limit_window_seconds: 18000,
|
||||||
|
reset_after_seconds: 600,
|
||||||
|
reset_at: resetAt,
|
||||||
|
used_percent: 21,
|
||||||
|
},
|
||||||
|
secondary_window: {
|
||||||
|
limit_window_seconds: 604800,
|
||||||
|
reset_after_seconds: 3600,
|
||||||
|
reset_at: resetAt + 600,
|
||||||
|
used_percent: 7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rate_limit_reached_type: null,
|
||||||
|
credits: {
|
||||||
|
has_credits: false,
|
||||||
|
overage_limit_reached: false,
|
||||||
|
unlimited: false,
|
||||||
|
},
|
||||||
|
spend_control: { reached: false },
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
});
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const refreshed = await refreshCodexAccount(1);
|
||||||
|
|
||||||
|
expect(refreshed.planType).toBe('pro');
|
||||||
|
expect(refreshed.email).toBe('live@example.com');
|
||||||
|
expect(refreshed.subscriptionUntil).toBeNull();
|
||||||
|
expect(refreshed.subscriptionSource).toBeNull();
|
||||||
|
expect(refreshed.liveStatus).toMatchObject({
|
||||||
|
source: 'wham/usage',
|
||||||
|
planType: 'pro',
|
||||||
|
rateLimit: {
|
||||||
|
allowed: true,
|
||||||
|
limitReached: false,
|
||||||
|
primaryWindow: {
|
||||||
|
limitWindowSeconds: 18000,
|
||||||
|
resetAfterSeconds: 600,
|
||||||
|
resetAt: new Date(resetAt * 1000).toISOString(),
|
||||||
|
usedPercent: 21,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
credits: {
|
||||||
|
hasCredits: false,
|
||||||
|
overageLimitReached: false,
|
||||||
|
unlimited: false,
|
||||||
|
},
|
||||||
|
spendControl: { reached: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sidecar = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(accountDir, 'plan-status.json'), 'utf-8'),
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
expect(sidecar).toMatchObject({
|
||||||
|
plan_type: 'pro',
|
||||||
|
rate_limit: {
|
||||||
|
primary_window: { used_percent: 21 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(listCodexAccounts()[0]).toMatchObject({
|
||||||
|
planType: 'pro',
|
||||||
|
subscriptionUntil: null,
|
||||||
|
liveStatus: { source: 'wham/usage' },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,21 @@ import {
|
|||||||
getActiveCodexAuthPath,
|
getActiveCodexAuthPath,
|
||||||
setCurrentCodexAccountIndex as setRotationIndex,
|
setCurrentCodexAccountIndex as setRotationIndex,
|
||||||
} from './codex-token-rotation.js';
|
} from './codex-token-rotation.js';
|
||||||
|
import {
|
||||||
|
normalizeCodexLiveStatus,
|
||||||
|
toCodexLiveStatusSummary,
|
||||||
|
type CodexLiveStatus,
|
||||||
|
type CodexLiveStatusSummary,
|
||||||
|
} from './codex-live-status.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CodexAdditionalRateLimitSummary,
|
||||||
|
CodexCreditsSummary,
|
||||||
|
CodexLiveStatusSummary,
|
||||||
|
CodexRateLimitSummary,
|
||||||
|
CodexRateLimitWindowSummary,
|
||||||
|
CodexSpendControlSummary,
|
||||||
|
} from './codex-live-status.js';
|
||||||
|
|
||||||
export interface ClaudeAccountSummary {
|
export interface ClaudeAccountSummary {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -37,6 +52,8 @@ export interface CodexAccountSummary {
|
|||||||
planType: string | null;
|
planType: string | null;
|
||||||
subscriptionUntil: string | null;
|
subscriptionUntil: string | null;
|
||||||
subscriptionLastChecked: string | null;
|
subscriptionLastChecked: string | null;
|
||||||
|
subscriptionSource: 'jwt-cache' | null;
|
||||||
|
liveStatus: CodexLiveStatusSummary | null;
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,12 +92,16 @@ function readJson<T>(file: string): T | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function settingsHomeDir(): string {
|
||||||
|
return process.env.EJCLAW_SETTINGS_HOME || os.homedir();
|
||||||
|
}
|
||||||
|
|
||||||
function claudeCredsPath(index: number): string {
|
function claudeCredsPath(index: number): string {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
return path.join(os.homedir(), '.claude', '.credentials.json');
|
return path.join(settingsHomeDir(), '.claude', '.credentials.json');
|
||||||
}
|
}
|
||||||
return path.join(
|
return path.join(
|
||||||
os.homedir(),
|
settingsHomeDir(),
|
||||||
'.claude-accounts',
|
'.claude-accounts',
|
||||||
String(index),
|
String(index),
|
||||||
'.credentials.json',
|
'.credentials.json',
|
||||||
@@ -89,9 +110,14 @@ function claudeCredsPath(index: number): string {
|
|||||||
|
|
||||||
function codexAuthPath(index: number): string {
|
function codexAuthPath(index: number): string {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
return path.join(os.homedir(), '.codex', 'auth.json');
|
return path.join(settingsHomeDir(), '.codex', 'auth.json');
|
||||||
}
|
}
|
||||||
return path.join(os.homedir(), '.codex-accounts', String(index), 'auth.json');
|
return path.join(
|
||||||
|
settingsHomeDir(),
|
||||||
|
'.codex-accounts',
|
||||||
|
String(index),
|
||||||
|
'auth.json',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readClaudeAccount(index: number): ClaudeAccountSummary | null {
|
function readClaudeAccount(index: number): ClaudeAccountSummary | null {
|
||||||
@@ -126,6 +152,8 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
|||||||
let planType: string | null = null;
|
let planType: string | null = null;
|
||||||
let subscriptionUntil: string | null = null;
|
let subscriptionUntil: string | null = null;
|
||||||
let subscriptionLastChecked: string | null = null;
|
let subscriptionLastChecked: string | null = null;
|
||||||
|
let subscriptionSource: 'jwt-cache' | null = null;
|
||||||
|
let liveStatus: CodexLiveStatusSummary | null = null;
|
||||||
const idToken = data?.tokens?.id_token;
|
const idToken = data?.tokens?.id_token;
|
||||||
if (idToken && typeof idToken === 'string') {
|
if (idToken && typeof idToken === 'string') {
|
||||||
const parts = idToken.split('.');
|
const parts = idToken.split('.');
|
||||||
@@ -145,6 +173,7 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
|||||||
}
|
}
|
||||||
if (typeof auth.chatgpt_subscription_active_until === 'string') {
|
if (typeof auth.chatgpt_subscription_active_until === 'string') {
|
||||||
subscriptionUntil = auth.chatgpt_subscription_active_until;
|
subscriptionUntil = auth.chatgpt_subscription_active_until;
|
||||||
|
subscriptionSource = 'jwt-cache';
|
||||||
}
|
}
|
||||||
if (typeof auth.chatgpt_subscription_last_checked === 'string') {
|
if (typeof auth.chatgpt_subscription_last_checked === 'string') {
|
||||||
subscriptionLastChecked = auth.chatgpt_subscription_last_checked;
|
subscriptionLastChecked = auth.chatgpt_subscription_last_checked;
|
||||||
@@ -156,10 +185,15 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Live wham/usage data, written by refreshCodexAccount, takes precedence.
|
// Live wham/usage data, written by refreshCodexAccount, takes precedence.
|
||||||
|
// When live status exists, do not surface the stale JWT active_until claim as
|
||||||
|
// a real billing expiry. OpenAI/Auth0 can cache that claim for days.
|
||||||
if (live) {
|
if (live) {
|
||||||
|
liveStatus = toCodexLiveStatusSummary(live);
|
||||||
if (typeof live.plan_type === 'string') planType = live.plan_type;
|
if (typeof live.plan_type === 'string') planType = live.plan_type;
|
||||||
if (typeof live.email === 'string') email = live.email;
|
if (typeof live.email === 'string') email = live.email;
|
||||||
subscriptionLastChecked = live.checked_at;
|
subscriptionLastChecked = live.checked_at;
|
||||||
|
subscriptionUntil = null;
|
||||||
|
subscriptionSource = null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
@@ -168,6 +202,8 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
|||||||
planType,
|
planType,
|
||||||
subscriptionUntil,
|
subscriptionUntil,
|
||||||
subscriptionLastChecked,
|
subscriptionLastChecked,
|
||||||
|
subscriptionSource,
|
||||||
|
liveStatus,
|
||||||
exists: true,
|
exists: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -176,7 +212,7 @@ export function listClaudeAccounts(): ClaudeAccountSummary[] {
|
|||||||
const out: ClaudeAccountSummary[] = [];
|
const out: ClaudeAccountSummary[] = [];
|
||||||
const def = readClaudeAccount(0);
|
const def = readClaudeAccount(0);
|
||||||
if (def) out.push(def);
|
if (def) out.push(def);
|
||||||
const dir = path.join(os.homedir(), '.claude-accounts');
|
const dir = path.join(settingsHomeDir(), '.claude-accounts');
|
||||||
if (fs.existsSync(dir)) {
|
if (fs.existsSync(dir)) {
|
||||||
const entries = fs.readdirSync(dir);
|
const entries = fs.readdirSync(dir);
|
||||||
const indices = entries
|
const indices = entries
|
||||||
@@ -196,7 +232,7 @@ export function listCodexAccounts(): CodexAccountSummary[] {
|
|||||||
const out: CodexAccountSummary[] = [];
|
const out: CodexAccountSummary[] = [];
|
||||||
const def = readCodexAccount(0);
|
const def = readCodexAccount(0);
|
||||||
if (def) out.push(def);
|
if (def) out.push(def);
|
||||||
const dir = path.join(os.homedir(), '.codex-accounts');
|
const dir = path.join(settingsHomeDir(), '.codex-accounts');
|
||||||
if (fs.existsSync(dir)) {
|
if (fs.existsSync(dir)) {
|
||||||
const entries = fs.readdirSync(dir);
|
const entries = fs.readdirSync(dir);
|
||||||
const indices = entries
|
const indices = entries
|
||||||
@@ -309,18 +345,12 @@ interface CodexAuthFile {
|
|||||||
last_refresh?: string;
|
last_refresh?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CodexLiveStatus {
|
|
||||||
checked_at: string;
|
|
||||||
plan_type?: string | null;
|
|
||||||
email?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function planStatusPath(index: number): string {
|
function planStatusPath(index: number): string {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
return path.join(os.homedir(), '.codex', 'plan-status.json');
|
return path.join(settingsHomeDir(), '.codex', 'plan-status.json');
|
||||||
}
|
}
|
||||||
return path.join(
|
return path.join(
|
||||||
os.homedir(),
|
settingsHomeDir(),
|
||||||
'.codex-accounts',
|
'.codex-accounts',
|
||||||
String(index),
|
String(index),
|
||||||
'plan-status.json',
|
'plan-status.json',
|
||||||
@@ -341,24 +371,17 @@ function writeCodexLiveStatus(index: number, status: CodexLiveStatus): void {
|
|||||||
fs.renameSync(tempPath, file);
|
fs.renameSync(tempPath, file);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCodexLivePlanType(
|
async function fetchCodexLiveUsage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
): Promise<{ plan_type?: string; email?: string } | null> {
|
): Promise<CodexLiveStatus | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(CODEX_USAGE_URL, {
|
const res = await fetch(CODEX_USAGE_URL, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { authorization: `Bearer ${accessToken}` },
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const json = (await res.json()) as {
|
const json = (await res.json()) as unknown;
|
||||||
plan_type?: unknown;
|
return normalizeCodexLiveStatus(json, new Date().toISOString());
|
||||||
email?: unknown;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
plan_type:
|
|
||||||
typeof json.plan_type === 'string' ? json.plan_type : undefined,
|
|
||||||
email: typeof json.email === 'string' ? json.email : undefined,
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -426,21 +449,17 @@ export async function refreshCodexAccount(
|
|||||||
};
|
};
|
||||||
writeCodexAuthFile(file, updated);
|
writeCodexAuthFile(file, updated);
|
||||||
|
|
||||||
// Hit chatgpt.com/backend-api/wham/usage to read the live plan_type
|
// Hit chatgpt.com/backend-api/wham/usage to read the live plan, rate limit,
|
||||||
// (the JWT id_token's chatgpt_plan_type / *_active_until / *_last_checked
|
// and spend-control state. The JWT id_token's chatgpt_plan_type /
|
||||||
// claims are cached on OpenAI's auth0 side and don't refresh on every
|
// *_active_until / *_last_checked claims are cached on OpenAI's Auth0 side and
|
||||||
// OAuth refresh_token grant). Persist the live values to a sidecar
|
// don't refresh on every OAuth refresh_token grant, so the dashboard must not
|
||||||
// plan-status.json so readCodexAccount can prefer them.
|
// present that cached expiry as live billing truth.
|
||||||
const accessToken =
|
const accessToken =
|
||||||
payload.access_token ?? data?.tokens?.access_token ?? null;
|
payload.access_token ?? data?.tokens?.access_token ?? null;
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
const live = await fetchCodexLivePlanType(accessToken);
|
const live = await fetchCodexLiveUsage(accessToken);
|
||||||
if (live) {
|
if (live) {
|
||||||
writeCodexLiveStatus(index, {
|
writeCodexLiveStatus(index, live);
|
||||||
checked_at: new Date().toISOString(),
|
|
||||||
plan_type: live.plan_type ?? null,
|
|
||||||
email: live.email ?? null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,7 +484,7 @@ export function getActiveCodexSettingsIndex(): number | null {
|
|||||||
if (codexAuthPath(0) === activePath && fs.existsSync(activePath)) {
|
if (codexAuthPath(0) === activePath && fs.existsSync(activePath)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const dir = path.join(os.homedir(), '.codex-accounts');
|
const dir = path.join(settingsHomeDir(), '.codex-accounts');
|
||||||
if (fs.existsSync(dir)) {
|
if (fs.existsSync(dir)) {
|
||||||
const indices = fs
|
const indices = fs
|
||||||
.readdirSync(dir)
|
.readdirSync(dir)
|
||||||
@@ -546,11 +565,11 @@ export function stopCodexAccountRefreshLoop(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function codexConfigPath(): string {
|
function codexConfigPath(): string {
|
||||||
return path.join(os.homedir(), '.codex', 'config.toml');
|
return path.join(settingsHomeDir(), '.codex', 'config.toml');
|
||||||
}
|
}
|
||||||
|
|
||||||
function claudeSettingsPath(): string {
|
function claudeSettingsPath(): string {
|
||||||
return path.join(os.homedir(), '.claude', 'settings.json');
|
return path.join(settingsHomeDir(), '.claude', 'settings.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCodexFastMode(): boolean {
|
function readCodexFastMode(): boolean {
|
||||||
@@ -677,8 +696,8 @@ export function removeAccountDirectory(
|
|||||||
}
|
}
|
||||||
const baseDir =
|
const baseDir =
|
||||||
provider === 'claude'
|
provider === 'claude'
|
||||||
? path.join(os.homedir(), '.claude-accounts', String(index))
|
? path.join(settingsHomeDir(), '.claude-accounts', String(index))
|
||||||
: path.join(os.homedir(), '.codex-accounts', String(index));
|
: path.join(settingsHomeDir(), '.codex-accounts', String(index));
|
||||||
if (!fs.existsSync(baseDir)) {
|
if (!fs.existsSync(baseDir)) {
|
||||||
throw new Error(`account directory not found: ${baseDir}`);
|
throw new Error(`account directory not found: ${baseDir}`);
|
||||||
}
|
}
|
||||||
@@ -707,7 +726,7 @@ export function addClaudeAccountFromToken(token: string): {
|
|||||||
const exp = typeof payload.exp === 'number' ? payload.exp * 1000 : 0;
|
const exp = typeof payload.exp === 'number' ? payload.exp * 1000 : 0;
|
||||||
const accountId = typeof payload.sub === 'string' ? payload.sub : null;
|
const accountId = typeof payload.sub === 'string' ? payload.sub : null;
|
||||||
|
|
||||||
const baseDir = path.join(os.homedir(), '.claude-accounts');
|
const baseDir = path.join(settingsHomeDir(), '.claude-accounts');
|
||||||
if (!fs.existsSync(baseDir)) {
|
if (!fs.existsSync(baseDir)) {
|
||||||
fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,6 +208,28 @@ function makeCodexAccount(): CodexAccountSummary {
|
|||||||
planType: 'pro',
|
planType: 'pro',
|
||||||
subscriptionUntil: null,
|
subscriptionUntil: null,
|
||||||
subscriptionLastChecked: '2026-04-28T08:00:00.000Z',
|
subscriptionLastChecked: '2026-04-28T08:00:00.000Z',
|
||||||
|
subscriptionSource: null,
|
||||||
|
liveStatus: {
|
||||||
|
checkedAt: '2026-04-28T08:00:00.000Z',
|
||||||
|
source: 'wham/usage',
|
||||||
|
planType: 'pro',
|
||||||
|
email: 'codex@example.com',
|
||||||
|
rateLimit: {
|
||||||
|
allowed: true,
|
||||||
|
limitReached: false,
|
||||||
|
primaryWindow: {
|
||||||
|
limitWindowSeconds: 18000,
|
||||||
|
resetAfterSeconds: 1200,
|
||||||
|
resetAt: '2026-04-28T08:20:00.000Z',
|
||||||
|
usedPercent: 12,
|
||||||
|
},
|
||||||
|
secondaryWindow: null,
|
||||||
|
},
|
||||||
|
rateLimitReachedType: null,
|
||||||
|
additionalRateLimits: [],
|
||||||
|
credits: null,
|
||||||
|
spendControl: null,
|
||||||
|
},
|
||||||
exists: true,
|
exists: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user