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:
Eyejoker
2026-05-23 11:12:55 +09:00
committed by GitHub
parent 763828e4f5
commit 58e5197dc6
9 changed files with 723 additions and 86 deletions

View File

@@ -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 ? '갱신중…' : '갱신'}

View File

@@ -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;
}

View 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',
};
}

View File

@@ -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);

View File

@@ -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
View 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,
};
}

View File

@@ -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' },
});
});
});

View File

@@ -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 });
}

View File

@@ -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,
};
}