Merge pull request #163 from erdemozyol/feat/codex-status-usage
Add Codex usage to /status
This commit is contained in:
211
src/components/Settings/CodexUsage.tsx
Normal file
211
src/components/Settings/CodexUsage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
204
src/services/api/codexUsage.test.ts
Normal file
204
src/services/api/codexUsage.test.ts
Normal 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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
434
src/services/api/codexUsage.ts
Normal file
434
src/services/api/codexUsage.ts
Normal 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())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user