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,
|
||||
updateModels,
|
||||
} from './api';
|
||||
import {
|
||||
formatDateTime,
|
||||
formatJwtCacheExpiry,
|
||||
formatLiveStatusBadge,
|
||||
formatUsageBadge,
|
||||
} from './codexAccountBadges';
|
||||
import { type Locale, type Messages } from './i18n';
|
||||
import { MoaSettingsPanel } from './MoaSettingsPanel';
|
||||
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() {
|
||||
const [data, setData] = useState<AccountData | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -702,8 +677,9 @@ function CodexAccounts({
|
||||
</ul>
|
||||
)}
|
||||
<p className="settings-hint">
|
||||
OAuth 토큰은 6시간마다 자동 갱신됩니다. plan 변경/해지가 즉시 반영되게
|
||||
하려면 수동으로 “전체 갱신”을 누르세요.
|
||||
“갱신”은 chatgpt.com wham/usage live plan·rate limit·지출 제한을
|
||||
저장합니다. JWT 만료일 캐시는 live 값이 없을 때만 fallback으로
|
||||
표시합니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -726,7 +702,14 @@ function CodexAccountRow({
|
||||
onSwitch: (index: number) => void;
|
||||
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 switching = perRowBusy === `switch:${acc.index}`;
|
||||
|
||||
@@ -746,16 +729,39 @@ function CodexAccountRow({
|
||||
<span className="settings-account-plan">
|
||||
{acc.planType ?? 'unknown'}
|
||||
</span>
|
||||
{expiry ? (
|
||||
{liveBadge ? (
|
||||
<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={
|
||||
acc.subscriptionLastChecked
|
||||
? `last checked: ${acc.subscriptionLastChecked.slice(0, 10)}`
|
||||
: undefined
|
||||
acc.liveStatus
|
||||
? 'wham/usage live checked_at'
|
||||
: 'JWT subscription_last_checked cache'
|
||||
}
|
||||
>
|
||||
{expiry.label}
|
||||
갱신 {checkedAt}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -764,7 +770,7 @@ function CodexAccountRow({
|
||||
className="settings-secondary"
|
||||
disabled={busy || perRowBusy !== null}
|
||||
onClick={() => onRefresh(acc.index)}
|
||||
title="OAuth 토큰을 다시 받아 구독 상태를 갱신합니다"
|
||||
title="OAuth 토큰을 다시 받고 wham/usage live plan·limit 상태를 갱신합니다"
|
||||
type="button"
|
||||
>
|
||||
{refreshing ? '갱신중…' : '갱신'}
|
||||
|
||||
@@ -362,6 +362,48 @@ export interface ClaudeAccountSummary {
|
||||
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 {
|
||||
index: number;
|
||||
accountId: string | null;
|
||||
@@ -369,6 +411,8 @@ export interface CodexAccountSummary {
|
||||
planType: string | null;
|
||||
subscriptionUntil: string | null;
|
||||
subscriptionLastChecked: string | null;
|
||||
subscriptionSource: 'jwt-cache' | null;
|
||||
liveStatus: CodexLiveStatusSummary | null;
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 12px;
|
||||
color: var(--bg-ink);
|
||||
|
||||
@@ -425,8 +425,30 @@ async function handleMockApi(route: Route, state: MockApiState) {
|
||||
accountId: 'acct_test',
|
||||
email: 'codex@example.com',
|
||||
planType: 'plus',
|
||||
subscriptionUntil: '2099-01-01T00:00:00.000Z',
|
||||
subscriptionUntil: null,
|
||||
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,
|
||||
},
|
||||
],
|
||||
|
||||
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 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', () => {
|
||||
let tempDir: string;
|
||||
let previousCwd: string;
|
||||
let previousCodexGoals: string | undefined;
|
||||
let previousHome: string | undefined;
|
||||
let previousSettingsHome: string | undefined;
|
||||
let previousFetch: typeof fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-settings-'));
|
||||
previousCwd = process.cwd();
|
||||
previousCodexGoals = process.env.CODEX_GOALS;
|
||||
previousHome = process.env.HOME;
|
||||
previousSettingsHome = process.env.EJCLAW_SETTINGS_HOME;
|
||||
previousFetch = globalThis.fetch;
|
||||
delete process.env.CODEX_GOALS;
|
||||
process.env.HOME = tempDir;
|
||||
process.env.EJCLAW_SETTINGS_HOME = tempDir;
|
||||
process.chdir(tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
globalThis.fetch = previousFetch;
|
||||
if (previousCodexGoals === undefined) {
|
||||
delete process.env.CODEX_GOALS;
|
||||
} else {
|
||||
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 });
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(getCodexFeatures()).toEqual({ goals: false });
|
||||
|
||||
@@ -42,4 +72,123 @@ describe('settings-store Codex features', () => {
|
||||
'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,
|
||||
setCurrentCodexAccountIndex as setRotationIndex,
|
||||
} 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 {
|
||||
index: number;
|
||||
@@ -37,6 +52,8 @@ export interface CodexAccountSummary {
|
||||
planType: string | null;
|
||||
subscriptionUntil: string | null;
|
||||
subscriptionLastChecked: string | null;
|
||||
subscriptionSource: 'jwt-cache' | null;
|
||||
liveStatus: CodexLiveStatusSummary | null;
|
||||
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 {
|
||||
if (index === 0) {
|
||||
return path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
return path.join(settingsHomeDir(), '.claude', '.credentials.json');
|
||||
}
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
settingsHomeDir(),
|
||||
'.claude-accounts',
|
||||
String(index),
|
||||
'.credentials.json',
|
||||
@@ -89,9 +110,14 @@ function claudeCredsPath(index: number): string {
|
||||
|
||||
function codexAuthPath(index: number): string {
|
||||
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 {
|
||||
@@ -126,6 +152,8 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
||||
let planType: string | null = null;
|
||||
let subscriptionUntil: 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;
|
||||
if (idToken && typeof idToken === 'string') {
|
||||
const parts = idToken.split('.');
|
||||
@@ -145,6 +173,7 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
||||
}
|
||||
if (typeof auth.chatgpt_subscription_active_until === 'string') {
|
||||
subscriptionUntil = auth.chatgpt_subscription_active_until;
|
||||
subscriptionSource = 'jwt-cache';
|
||||
}
|
||||
if (typeof auth.chatgpt_subscription_last_checked === 'string') {
|
||||
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.
|
||||
// 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) {
|
||||
liveStatus = toCodexLiveStatusSummary(live);
|
||||
if (typeof live.plan_type === 'string') planType = live.plan_type;
|
||||
if (typeof live.email === 'string') email = live.email;
|
||||
subscriptionLastChecked = live.checked_at;
|
||||
subscriptionUntil = null;
|
||||
subscriptionSource = null;
|
||||
}
|
||||
return {
|
||||
index,
|
||||
@@ -168,6 +202,8 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
||||
planType,
|
||||
subscriptionUntil,
|
||||
subscriptionLastChecked,
|
||||
subscriptionSource,
|
||||
liveStatus,
|
||||
exists: true,
|
||||
};
|
||||
}
|
||||
@@ -176,7 +212,7 @@ export function listClaudeAccounts(): ClaudeAccountSummary[] {
|
||||
const out: ClaudeAccountSummary[] = [];
|
||||
const def = readClaudeAccount(0);
|
||||
if (def) out.push(def);
|
||||
const dir = path.join(os.homedir(), '.claude-accounts');
|
||||
const dir = path.join(settingsHomeDir(), '.claude-accounts');
|
||||
if (fs.existsSync(dir)) {
|
||||
const entries = fs.readdirSync(dir);
|
||||
const indices = entries
|
||||
@@ -196,7 +232,7 @@ export function listCodexAccounts(): CodexAccountSummary[] {
|
||||
const out: CodexAccountSummary[] = [];
|
||||
const def = readCodexAccount(0);
|
||||
if (def) out.push(def);
|
||||
const dir = path.join(os.homedir(), '.codex-accounts');
|
||||
const dir = path.join(settingsHomeDir(), '.codex-accounts');
|
||||
if (fs.existsSync(dir)) {
|
||||
const entries = fs.readdirSync(dir);
|
||||
const indices = entries
|
||||
@@ -309,18 +345,12 @@ interface CodexAuthFile {
|
||||
last_refresh?: string;
|
||||
}
|
||||
|
||||
interface CodexLiveStatus {
|
||||
checked_at: string;
|
||||
plan_type?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
function planStatusPath(index: number): string {
|
||||
if (index === 0) {
|
||||
return path.join(os.homedir(), '.codex', 'plan-status.json');
|
||||
return path.join(settingsHomeDir(), '.codex', 'plan-status.json');
|
||||
}
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
settingsHomeDir(),
|
||||
'.codex-accounts',
|
||||
String(index),
|
||||
'plan-status.json',
|
||||
@@ -341,24 +371,17 @@ function writeCodexLiveStatus(index: number, status: CodexLiveStatus): void {
|
||||
fs.renameSync(tempPath, file);
|
||||
}
|
||||
|
||||
async function fetchCodexLivePlanType(
|
||||
async function fetchCodexLiveUsage(
|
||||
accessToken: string,
|
||||
): Promise<{ plan_type?: string; email?: string } | null> {
|
||||
): Promise<CodexLiveStatus | null> {
|
||||
try {
|
||||
const res = await fetch(CODEX_USAGE_URL, {
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as {
|
||||
plan_type?: unknown;
|
||||
email?: unknown;
|
||||
};
|
||||
return {
|
||||
plan_type:
|
||||
typeof json.plan_type === 'string' ? json.plan_type : undefined,
|
||||
email: typeof json.email === 'string' ? json.email : undefined,
|
||||
};
|
||||
const json = (await res.json()) as unknown;
|
||||
return normalizeCodexLiveStatus(json, new Date().toISOString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -426,21 +449,17 @@ export async function refreshCodexAccount(
|
||||
};
|
||||
writeCodexAuthFile(file, updated);
|
||||
|
||||
// Hit chatgpt.com/backend-api/wham/usage to read the live plan_type
|
||||
// (the JWT id_token's chatgpt_plan_type / *_active_until / *_last_checked
|
||||
// claims are cached on OpenAI's auth0 side and don't refresh on every
|
||||
// OAuth refresh_token grant). Persist the live values to a sidecar
|
||||
// plan-status.json so readCodexAccount can prefer them.
|
||||
// Hit chatgpt.com/backend-api/wham/usage to read the live plan, rate limit,
|
||||
// and spend-control state. The JWT id_token's chatgpt_plan_type /
|
||||
// *_active_until / *_last_checked claims are cached on OpenAI's Auth0 side and
|
||||
// don't refresh on every OAuth refresh_token grant, so the dashboard must not
|
||||
// present that cached expiry as live billing truth.
|
||||
const accessToken =
|
||||
payload.access_token ?? data?.tokens?.access_token ?? null;
|
||||
if (accessToken) {
|
||||
const live = await fetchCodexLivePlanType(accessToken);
|
||||
const live = await fetchCodexLiveUsage(accessToken);
|
||||
if (live) {
|
||||
writeCodexLiveStatus(index, {
|
||||
checked_at: new Date().toISOString(),
|
||||
plan_type: live.plan_type ?? null,
|
||||
email: live.email ?? null,
|
||||
});
|
||||
writeCodexLiveStatus(index, live);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,7 +484,7 @@ export function getActiveCodexSettingsIndex(): number | null {
|
||||
if (codexAuthPath(0) === activePath && fs.existsSync(activePath)) {
|
||||
return 0;
|
||||
}
|
||||
const dir = path.join(os.homedir(), '.codex-accounts');
|
||||
const dir = path.join(settingsHomeDir(), '.codex-accounts');
|
||||
if (fs.existsSync(dir)) {
|
||||
const indices = fs
|
||||
.readdirSync(dir)
|
||||
@@ -546,11 +565,11 @@ export function stopCodexAccountRefreshLoop(): void {
|
||||
}
|
||||
|
||||
function codexConfigPath(): string {
|
||||
return path.join(os.homedir(), '.codex', 'config.toml');
|
||||
return path.join(settingsHomeDir(), '.codex', 'config.toml');
|
||||
}
|
||||
|
||||
function claudeSettingsPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'settings.json');
|
||||
return path.join(settingsHomeDir(), '.claude', 'settings.json');
|
||||
}
|
||||
|
||||
function readCodexFastMode(): boolean {
|
||||
@@ -677,8 +696,8 @@ export function removeAccountDirectory(
|
||||
}
|
||||
const baseDir =
|
||||
provider === 'claude'
|
||||
? path.join(os.homedir(), '.claude-accounts', String(index))
|
||||
: path.join(os.homedir(), '.codex-accounts', String(index));
|
||||
? path.join(settingsHomeDir(), '.claude-accounts', String(index))
|
||||
: path.join(settingsHomeDir(), '.codex-accounts', String(index));
|
||||
if (!fs.existsSync(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 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)) {
|
||||
fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
@@ -208,6 +208,28 @@ function makeCodexAccount(): CodexAccountSummary {
|
||||
planType: 'pro',
|
||||
subscriptionUntil: null,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user