Add Codex usage to /status
This commit is contained in:
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