Merge pull request #163 from erdemozyol/feat/codex-status-usage

Add Codex usage to /status
This commit is contained in:
Kevin Codex
2026-04-02 23:54:07 +08:00
committed by GitHub
5 changed files with 861 additions and 4 deletions

View File

@@ -0,0 +1,211 @@
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, Text } from '../../ink.js'
import { useKeybinding } from '../../keybindings/useKeybinding.js'
import {
buildCodexUsageRows,
fetchCodexUsage,
formatCodexPlanType,
type CodexUsageData,
type CodexUsageRow,
} from '../../services/api/codexUsage.js'
import { formatResetText } from '../../utils/format.js'
import { logError } from '../../utils/log.js'
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
import { Byline } from '../design-system/Byline.js'
import { ProgressBar } from '../design-system/ProgressBar.js'
type CodexUsageLimitBarProps = {
label: string
usedPercent: number
resetsAt?: string
maxWidth: number
}
function CodexUsageLimitBar({
label,
usedPercent,
resetsAt,
maxWidth,
}: CodexUsageLimitBarProps): React.ReactNode {
const normalizedUsedPercent = Math.max(0, Math.min(100, usedPercent))
const usedText = `${Math.floor(normalizedUsedPercent)}% used`
const resetText = resetsAt
? `Resets ${formatResetText(resetsAt, true, true)}`
: undefined
if (maxWidth >= 62) {
return (
<Box flexDirection="column">
<Text bold>{label}</Text>
<Box flexDirection="row" gap={1}>
<ProgressBar
ratio={normalizedUsedPercent / 100}
width={50}
fillColor="rate_limit_fill"
emptyColor="rate_limit_empty"
/>
<Text>{usedText}</Text>
</Box>
{resetText ? <Text dimColor>{resetText}</Text> : null}
</Box>
)
}
return (
<Box flexDirection="column">
<Text>
<Text bold>{label}</Text>
{resetText ? (
<>
<Text> </Text>
<Text dimColor>· {resetText}</Text>
</>
) : null}
</Text>
<ProgressBar
ratio={normalizedUsedPercent / 100}
width={maxWidth}
fillColor="rate_limit_fill"
emptyColor="rate_limit_empty"
/>
<Text>{usedText}</Text>
</Box>
)
}
function CodexUsageTextRow({
label,
value,
}: Extract<CodexUsageRow, { kind: 'text' }>): React.ReactNode {
if (!value) {
return <Text bold>{label}</Text>
}
return (
<Text>
<Text bold>{label}</Text>
<Text dimColor> · {value}</Text>
</Text>
)
}
export function CodexUsage(): React.ReactNode {
const [usage, setUsage] = useState<CodexUsageData | null>(null)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const { columns } = useTerminalSize()
const availableWidth = columns - 2
const maxWidth = Math.min(availableWidth, 80)
const loadUsage = React.useCallback(async () => {
setIsLoading(true)
setError(null)
try {
setUsage(await fetchCodexUsage())
} catch (err) {
logError(err as Error)
setError(err instanceof Error ? err.message : 'Failed to load Codex usage')
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
void loadUsage()
}, [loadUsage])
useKeybinding(
'settings:retry',
() => {
void loadUsage()
},
{
context: 'Settings',
isActive: !!error && !isLoading,
},
)
if (error) {
return (
<Box flexDirection="column" gap={1}>
<Text color="error">Error: {error}</Text>
<Text dimColor>
<Byline>
<ConfigurableShortcutHint
action="settings:retry"
context="Settings"
fallback="r"
description="retry"
/>
<ConfigurableShortcutHint
action="confirm:no"
context="Settings"
fallback="Esc"
description="cancel"
/>
</Byline>
</Text>
</Box>
)
}
if (!usage) {
return (
<Box flexDirection="column" gap={1}>
<Text dimColor>Loading Codex usage data</Text>
<Text dimColor>
<ConfigurableShortcutHint
action="confirm:no"
context="Settings"
fallback="Esc"
description="cancel"
/>
</Text>
</Box>
)
}
const rows = buildCodexUsageRows(usage.snapshots)
const planType = formatCodexPlanType(usage.planType)
return (
<Box flexDirection="column" gap={1} width="100%">
{planType ? <Text dimColor>Plan: {planType}</Text> : null}
{rows.length === 0 ? (
<Text dimColor>Codex usage data is not available for this account.</Text>
) : null}
{rows.map((row, index) =>
row.kind === 'window' ? (
<CodexUsageLimitBar
key={`${row.label}-${index}`}
label={row.label}
usedPercent={row.usedPercent}
resetsAt={row.resetsAt}
maxWidth={maxWidth}
/>
) : (
<CodexUsageTextRow
key={`${row.label}-${index}`}
label={row.label}
value={row.value}
/>
),
)}
<Text dimColor>
<ConfigurableShortcutHint
action="confirm:no"
context="Settings"
fallback="Esc"
description="cancel"
/>
</Text>
</Box>
)
}

View File

@@ -10,11 +10,13 @@ import { useKeybinding } from '../../keybindings/useKeybinding.js';
import { type ExtraUsage, fetchUtilization, type RateLimit, type Utilization } from '../../services/api/usage.js'; import { type ExtraUsage, fetchUtilization, type RateLimit, type Utilization } from '../../services/api/usage.js';
import { formatResetText } from '../../utils/format.js'; import { formatResetText } from '../../utils/format.js';
import { logError } from '../../utils/log.js'; import { logError } from '../../utils/log.js';
import { getAPIProvider } from '../../utils/model/providers.js';
import { jsonStringify } from '../../utils/slowOperations.js'; import { jsonStringify } from '../../utils/slowOperations.js';
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
import { Byline } from '../design-system/Byline.js'; import { Byline } from '../design-system/Byline.js';
import { ProgressBar } from '../design-system/ProgressBar.js'; import { ProgressBar } from '../design-system/ProgressBar.js';
import { isEligibleForOverageCreditGrant, OverageCreditUpsell } from '../LogoV2/OverageCreditUpsell.js'; import { isEligibleForOverageCreditGrant, OverageCreditUpsell } from '../LogoV2/OverageCreditUpsell.js';
import { CodexUsage } from './CodexUsage.js';
type LimitBarProps = { type LimitBarProps = {
title: string; title: string;
limit: RateLimit; limit: RateLimit;
@@ -171,7 +173,7 @@ function LimitBar(t0) {
return t8; return t8;
} }
} }
export function Usage(): React.ReactNode { function AnthropicUsage(): React.ReactNode {
const [utilization, setUtilization] = useState<Utilization | null>(null); const [utilization, setUtilization] = useState<Utilization | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -263,6 +265,12 @@ export function Usage(): React.ReactNode {
</Text> </Text>
</Box>; </Box>;
} }
export function Usage(): React.ReactNode {
if (getAPIProvider() === 'codex') {
return <CodexUsage />;
}
return <AnthropicUsage />;
}
type ExtraUsageSectionProps = { type ExtraUsageSectionProps = {
extraUsage: ExtraUsage; extraUsage: ExtraUsage;
maxWidth: number; maxWidth: number;

View File

@@ -199,7 +199,7 @@ export function Tabs(t0) {
const t12 = 0; const t12 = 0;
const t13 = true; const t13 = true;
const t14 = modalScrollRef ? 0 : undefined; const t14 = modalScrollRef ? 0 : undefined;
const t15 = !hidden && <Box flexDirection="row" gap={1} flexShrink={modalScrollRef ? 0 : undefined}>{title !== undefined && <Text bold={true} color={color}>{title}</Text>}{tabs.map((t16, i) => { const t15 = !hidden && <Box key={`${selectedTabIndex}-${headerFocused ? "focused" : "blurred"}`} flexDirection="row" gap={1} flexShrink={modalScrollRef ? 0 : undefined}>{title !== undefined && <Text bold={true} color={color}>{title}</Text>}{tabs.map((t16, i) => {
const [id, title_0] = t16; const [id, title_0] = t16;
const isCurrent = selectedTabIndex === i; const isCurrent = selectedTabIndex === i;
const hasColorCursor = color && isCurrent && headerFocused; const hasColorCursor = color && isCurrent && headerFocused;

View File

@@ -0,0 +1,204 @@
import { describe, expect, test } from 'bun:test'
import {
buildCodexUsageRows,
formatCodexPlanType,
getCodexUsageUrl,
normalizeCodexUsagePayload,
} from './codexUsage.js'
describe('normalizeCodexUsagePayload', () => {
test('normalizes live Codex usage payloads from /backend-api/wham/usage', () => {
const usage = normalizeCodexUsagePayload({
plan_type: 'plus',
rate_limit: {
primary_window: {
used_percent: 38,
limit_window_seconds: 18_000,
reset_at: 1_775_154_358,
},
secondary_window: {
used_percent: 32,
limit_window_seconds: 604_800,
reset_at: 1_775_685_041,
},
},
code_review_rate_limit: {
primary_window: {
used_percent: 0,
limit_window_seconds: 604_800,
reset_at: 1_775_744_471,
},
secondary_window: null,
},
credits: {
has_credits: false,
unlimited: false,
balance: '0',
},
})
expect(usage.planType).toBe('plus')
expect(usage.snapshots).toHaveLength(2)
expect(usage.snapshots[0]).toMatchObject({
limitName: 'codex',
primary: {
usedPercent: 38,
windowMinutes: 300,
},
secondary: {
usedPercent: 32,
windowMinutes: 10_080,
},
})
expect(usage.snapshots[1]).toMatchObject({
limitName: 'code review',
primary: {
usedPercent: 0,
windowMinutes: 10_080,
},
})
})
test('supports direct protocol-style snapshot collections', () => {
const usage = normalizeCodexUsagePayload({
rateLimitsByLimitId: {
codex: {
limit_name: 'codex',
primary: {
used_percent: 12,
window_minutes: 300,
resets_at: 1_700_000_000,
},
credits: {
has_credits: true,
unlimited: false,
balance: '25',
},
},
},
})
expect(usage.snapshots).toEqual([
{
limitName: 'codex',
primary: {
usedPercent: 12,
windowMinutes: 300,
resetsAt: new Date(1_700_000_000 * 1000).toISOString(),
},
secondary: undefined,
credits: {
hasCredits: true,
unlimited: false,
balance: '25',
},
},
])
})
})
describe('buildCodexUsageRows', () => {
test('builds Codex-like labels for primary and secondary windows', () => {
const rows = buildCodexUsageRows([
{
limitName: 'codex',
primary: {
usedPercent: 38,
windowMinutes: 300,
resetsAt: '2026-04-02T10:00:00.000Z',
},
secondary: {
usedPercent: 32,
windowMinutes: 10_080,
resetsAt: '2026-04-09T10:00:00.000Z',
},
},
{
limitName: 'code review',
primary: {
usedPercent: 0,
windowMinutes: 10_080,
resetsAt: '2026-04-09T10:00:00.000Z',
},
},
])
expect(rows).toEqual([
{
kind: 'window',
label: '5h limit',
usedPercent: 38,
resetsAt: '2026-04-02T10:00:00.000Z',
},
{
kind: 'window',
label: 'Weekly limit',
usedPercent: 32,
resetsAt: '2026-04-09T10:00:00.000Z',
},
{
kind: 'window',
label: 'Code review Weekly limit',
usedPercent: 0,
resetsAt: '2026-04-09T10:00:00.000Z',
},
])
})
test('renders credits rows only when credits are available', () => {
const rows = buildCodexUsageRows([
{
limitName: 'codex',
credits: {
hasCredits: true,
unlimited: false,
balance: '25.2',
},
},
{
limitName: 'code review',
credits: {
hasCredits: true,
unlimited: true,
},
},
{
limitName: 'other',
credits: {
hasCredits: true,
unlimited: false,
balance: '0',
},
},
])
expect(rows).toEqual([
{
kind: 'text',
label: 'Credits',
value: '25 credits',
},
{
kind: 'text',
label: 'Code review limit',
value: '',
},
{
kind: 'text',
label: 'Credits',
value: 'Unlimited',
},
])
})
})
describe('Codex usage helpers', () => {
test('formats plan labels and usage endpoint url', () => {
expect(formatCodexPlanType('team_max')).toBe('Team Max')
expect(getCodexUsageUrl()).toBe('https://chatgpt.com/backend-api/wham/usage')
expect(getCodexUsageUrl('https://chatgpt.com/backend-api/codex')).toBe(
'https://chatgpt.com/backend-api/wham/usage',
)
})
})

View File

@@ -0,0 +1,434 @@
import {
DEFAULT_CODEX_BASE_URL,
isCodexBaseUrl,
resolveCodexApiCredentials,
resolveProviderRequest,
} from './providerConfig.js'
export type CodexUsageWindow = {
usedPercent: number
windowMinutes?: number
resetsAt?: string
}
export type CodexUsageCredits = {
hasCredits: boolean
unlimited: boolean
balance?: string
}
export type CodexUsageSnapshot = {
limitName: string
primary?: CodexUsageWindow
secondary?: CodexUsageWindow
credits?: CodexUsageCredits
}
export type CodexUsageData = {
planType?: string
snapshots: CodexUsageSnapshot[]
}
export type CodexUsageRow =
| {
kind: 'window'
label: string
usedPercent: number
resetsAt?: string
}
| {
kind: 'text'
label: string
value: string
}
type RecordLike = Record<string, unknown>
function isRecord(value: unknown): value is RecordLike {
return typeof value === 'object' && value !== null
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function asNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
function asBoolean(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
function toIsoFromUnixSeconds(value: unknown): string | undefined {
const seconds = asNumber(value)
if (seconds === undefined) return undefined
return new Date(seconds * 1000).toISOString()
}
function normalizeWindow(value: unknown): CodexUsageWindow | undefined {
if (!isRecord(value)) return undefined
const usedPercent =
asNumber(value.used_percent) ?? asNumber(value.usedPercent)
if (usedPercent === undefined) return undefined
const windowMinutes =
asNumber(value.window_minutes) ??
asNumber(value.windowDurationMins) ??
(() => {
const seconds = asNumber(value.limit_window_seconds)
return seconds === undefined ? undefined : Math.round(seconds / 60)
})()
const resetsAt =
toIsoFromUnixSeconds(value.resets_at) ??
toIsoFromUnixSeconds(value.resetsAt) ??
toIsoFromUnixSeconds(value.reset_at)
return {
usedPercent,
windowMinutes,
resetsAt,
}
}
function normalizeCredits(value: unknown): CodexUsageCredits | undefined {
if (!isRecord(value)) return undefined
const hasCredits =
asBoolean(value.has_credits) ?? asBoolean(value.hasCredits) ?? false
const unlimited = asBoolean(value.unlimited) ?? false
const balance = asString(value.balance)
if (!hasCredits && !unlimited && !balance) {
return undefined
}
return {
hasCredits,
unlimited,
balance,
}
}
function normalizeSnapshot(
value: unknown,
fallbackLimitName: string,
): CodexUsageSnapshot | undefined {
if (!isRecord(value)) return undefined
const limitName =
asString(value.limit_name) ??
asString(value.limitName) ??
asString(value.limit_id) ??
asString(value.limitId) ??
fallbackLimitName
const primary =
normalizeWindow(value.primary) ?? normalizeWindow(value.primary_window)
const secondary =
normalizeWindow(value.secondary) ?? normalizeWindow(value.secondary_window)
const credits = normalizeCredits(value.credits)
if (!primary && !secondary && !credits) {
return undefined
}
return {
limitName,
primary,
secondary,
credits,
}
}
function normalizeSnapshotsFromCollection(
value: unknown,
defaultLimitName = 'codex',
): CodexUsageSnapshot[] {
if (Array.isArray(value)) {
return value
.map((item, index) =>
normalizeSnapshot(
item,
index === 0 ? defaultLimitName : `${defaultLimitName}-${index + 1}`,
),
)
.filter((item): item is CodexUsageSnapshot => item !== undefined)
}
if (!isRecord(value)) return []
return Object.entries(value)
.map(([key, entry]) => normalizeSnapshot(entry, key))
.filter((item): item is CodexUsageSnapshot => item !== undefined)
}
function normalizeLiveUsagePayload(payload: RecordLike): CodexUsageData {
const planType = asString(payload.plan_type) ?? asString(payload.planType)
const snapshots: CodexUsageSnapshot[] = []
const codexCredits = normalizeCredits(payload.credits)
const codexSnapshot = normalizeSnapshot(payload.rate_limit, 'codex')
if (codexSnapshot) {
codexSnapshot.credits ??= codexCredits
snapshots.push(codexSnapshot)
} else if (codexCredits) {
snapshots.push({
limitName: 'codex',
credits: codexCredits,
})
}
const codeReviewSnapshot = normalizeSnapshot(
payload.code_review_rate_limit,
'code review',
)
if (codeReviewSnapshot) {
snapshots.push(codeReviewSnapshot)
}
snapshots.push(
...normalizeSnapshotsFromCollection(
payload.additional_rate_limits ?? payload.additionalRateLimits,
'additional',
),
)
return {
planType,
snapshots,
}
}
export function normalizeCodexUsagePayload(payload: unknown): CodexUsageData {
if (Array.isArray(payload)) {
return {
snapshots: normalizeSnapshotsFromCollection(payload),
}
}
if (!isRecord(payload)) {
return { snapshots: [] }
}
if (
'rate_limit' in payload ||
'code_review_rate_limit' in payload ||
'additional_rate_limits' in payload ||
'credits' in payload
) {
return normalizeLiveUsagePayload(payload)
}
const collection =
payload.rate_limits ??
payload.rateLimits ??
payload.rate_limits_by_limit_id ??
payload.rateLimitsByLimitId
if (collection !== undefined) {
return {
planType: asString(payload.plan_type) ?? asString(payload.planType),
snapshots: normalizeSnapshotsFromCollection(collection),
}
}
const snapshot = normalizeSnapshot(payload, 'codex')
return {
planType: asString(payload.plan_type) ?? asString(payload.planType),
snapshots: snapshot ? [snapshot] : [],
}
}
function capitalizeFirst(value: string): string {
if (!value) return value
return value[0]!.toUpperCase() + value.slice(1)
}
function formatWindowDuration(
windowMinutes: number | undefined,
fallback: string,
): string {
if (windowMinutes === undefined || windowMinutes <= 0) {
return fallback
}
if (windowMinutes === 60 * 24 * 7) {
return 'weekly'
}
if (windowMinutes % (60 * 24) === 0) {
return `${windowMinutes / (60 * 24)}d`
}
if (windowMinutes % 60 === 0) {
return `${windowMinutes / 60}h`
}
return `${windowMinutes}m`
}
function formatCreditBalance(rawBalance: string | undefined): string | undefined {
const balance = rawBalance?.trim()
if (!balance) return undefined
const intValue = Number.parseInt(balance, 10)
if (Number.isFinite(intValue) && `${intValue}` === balance && intValue > 0) {
return `${intValue}`
}
const floatValue = Number.parseFloat(balance)
if (Number.isFinite(floatValue) && floatValue > 0) {
return `${Math.round(floatValue)}`
}
return undefined
}
function buildCreditsRow(
credits: CodexUsageCredits | undefined,
): CodexUsageRow | undefined {
if (!credits?.hasCredits) return undefined
if (credits.unlimited) {
return {
kind: 'text',
label: 'Credits',
value: 'Unlimited',
}
}
const displayBalance = formatCreditBalance(credits.balance)
if (!displayBalance) return undefined
return {
kind: 'text',
label: 'Credits',
value: `${displayBalance} credits`,
}
}
export function buildCodexUsageRows(
snapshots: CodexUsageSnapshot[],
): CodexUsageRow[] {
const rows: CodexUsageRow[] = []
for (const snapshot of snapshots) {
const limitBucketLabel = snapshot.limitName.trim() || 'codex'
const creditsRow = buildCreditsRow(snapshot.credits)
const hasRenderableContent =
snapshot.primary !== undefined ||
snapshot.secondary !== undefined ||
creditsRow !== undefined
if (!hasRenderableContent) {
continue
}
const showLimitPrefix = limitBucketLabel.toLowerCase() !== 'codex'
const windowCount =
Number(snapshot.primary !== undefined) +
Number(snapshot.secondary !== undefined)
const combineNonCodexSingleLimit = showLimitPrefix && windowCount === 1
if (showLimitPrefix && !combineNonCodexSingleLimit) {
rows.push({
kind: 'text',
label: `${capitalizeFirst(limitBucketLabel)} limit`,
value: '',
})
}
if (snapshot.primary) {
const durationLabel = capitalizeFirst(
formatWindowDuration(snapshot.primary.windowMinutes, '5h'),
)
rows.push({
kind: 'window',
label: combineNonCodexSingleLimit
? `${capitalizeFirst(limitBucketLabel)} ${durationLabel} limit`
: `${durationLabel} limit`,
usedPercent: snapshot.primary.usedPercent,
resetsAt: snapshot.primary.resetsAt,
})
}
if (snapshot.secondary) {
const durationLabel = capitalizeFirst(
formatWindowDuration(snapshot.secondary.windowMinutes, 'weekly'),
)
rows.push({
kind: 'window',
label: combineNonCodexSingleLimit
? `${capitalizeFirst(limitBucketLabel)} ${durationLabel} limit`
: `${durationLabel} limit`,
usedPercent: snapshot.secondary.usedPercent,
resetsAt: snapshot.secondary.resetsAt,
})
}
if (creditsRow) {
rows.push(creditsRow)
}
}
return rows
}
export function formatCodexPlanType(
planType: string | undefined,
): string | undefined {
if (!planType) return undefined
return planType
.split(/[_\s-]+/)
.filter(Boolean)
.map(part => capitalizeFirst(part.toLowerCase()))
.join(' ')
}
export function getCodexUsageUrl(baseUrl = DEFAULT_CODEX_BASE_URL): string {
return new URL('/backend-api/wham/usage', baseUrl).toString()
}
export async function fetchCodexUsage(): Promise<CodexUsageData> {
const request = resolveProviderRequest({
model: process.env.OPENAI_MODEL,
baseUrl: process.env.OPENAI_BASE_URL,
})
if (!isCodexBaseUrl(request.baseUrl)) {
throw new Error(
'Codex usage is only available with the official ChatGPT Codex backend.',
)
}
const credentials = resolveCodexApiCredentials()
if (!credentials.apiKey) {
const authHint = credentials.authPath
? ` or place a Codex auth.json at ${credentials.authPath}`
: ''
throw new Error(`Codex auth is required. Set CODEX_API_KEY${authHint}.`)
}
if (!credentials.accountId) {
throw new Error(
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
)
}
const response = await fetch(getCodexUsageUrl(request.baseUrl), {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${credentials.apiKey}`,
'chatgpt-account-id': credentials.accountId,
originator: 'openclaude',
},
signal: AbortSignal.timeout(5000),
})
if (!response.ok) {
const errorBody = await response.text().catch(() => 'unknown error')
throw new Error(`Codex usage error ${response.status}: ${errorBody}`)
}
return normalizeCodexUsagePayload(await response.json())
}