feat(codex): add credits balance to quota query

- Parse Codex usage credits balance and unlimited status
- Display credits in quota cards and auth file panels
- Add i18n labels and related tests
This commit is contained in:
lzt404 committed 2026-10-05 00:45:17 +08:00
1 parent a79140b8d2
commit 2b81856521
8 files changed
+54 -3

No files matched your search

+7 -2
View File
@@ -24,10 +24,14 @@ export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset,
const fileDisabled = readBoolean(file, 'disabled');
const showReset = Boolean(onReset && (quota.resetCredits ?? 0) > 0);
const resetCreditsLabel = quota.resetCredits !== undefined ? t('authFiles.quota.resets', { count: quota.resetCredits }) : '';
const accountCreditsLabel = quota.creditsUnlimited
? t('quota.creditUnlimited')
: quota.creditBalance !== undefined ? quota.creditBalance : '';
const denseMetadata = dense ? [
quota.resetCreditsApplicable !== undefined ? t('quota.resetApplicable', { count: quota.resetCreditsApplicable }) : '',
quota.subscriptionActiveUntil ? t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) }) : '',
quota.resetCreditsEarliestExpiry ? t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) }) : '',
accountCreditsLabel ? t('quota.creditBalance') + ': ' + accountCreditsLabel : '',
].filter(Boolean).join(' · ') : '';
const renderRow = (row: QuotaState['rows'][number], index: number) => {
const percent = row.remainingPercent !== null && Number.isFinite(row.remainingPercent) ? Math.max(0, Math.min(100, row.remainingPercent)) : null;
@@ -51,9 +55,9 @@ export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset,
{!compactLayout || showReset || (dense && resetCreditsLabel) ? <div className="credential-quota-heading">
{!compactLayout ? <strong>{t('authFiles.settings.quotaRemaining')}</strong> : null}
{!compactLayout && quota.plan ? <span className="credential-quota-plan">{quota.plan}</span> : null}
{dense && resetCreditsLabel ? <small className="credential-quota-reset-credits">{resetCreditsLabel}</small> : null}
{showReset ? <button type="button" className="secondary-button compact-button credential-quota-reset" onClick={onReset} disabled={disabled || !canResetCodexQuota(file, quota)}
title={dense ? [t('quota.reset'), resetCreditsLabel, denseMetadata].filter(Boolean).join(' · ') : t('quota.reset')}>{dense ? resetCreditsLabel : t('quota.reset')}</button> : null}
{dense && !showReset && resetCreditsLabel ? <small className="credential-quota-reset-credits">{resetCreditsLabel}</small> : null}
title={t('quota.reset')}>{t('quota.reset')}</button> : null}
{!compactLayout ? <button type="button" className="credential-quota-refresh" disabled={disabled || loading} onClick={onRefresh}
title={disabled ? t('quota.fileDisabled') : t('authFiles.quota.refresh')}>
{loading ? <LoaderCircle size={13} className="spin" /> : <RefreshCw size={13} />}
@@ -78,6 +82,7 @@ export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset,
{!dense && quota.resetCredits !== undefined ? <small>{resetCreditsLabel}</small> : null}
{quota.resetCreditsApplicable !== undefined ? <small>{t('quota.resetApplicable', { count: quota.resetCreditsApplicable })}</small> : null}
{quota.subscriptionActiveUntil ? <small>{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</small> : null}
{accountCreditsLabel ? <small>{t('quota.creditBalance')}: {accountCreditsLabel}</small> : null}
{quota.resetCreditsEarliestExpiry ? <small>{t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) })}</small> : null}
</div> : null}
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
+2
View File
@@ -472,6 +472,8 @@ export const jaOverrides = {
'quota.fetch': 'クォータを取得',
'quota.querying': '照会中',
'quota.resetCredits': '手動リセット回数',
'quota.creditBalance': 'クレジット残高',
'quota.creditUnlimited': '無制限',
'quota.earliestExpiry': '最も早い有効期限',
'quota.remaining': '残り {percent}%',
'quota.resetPassed': 'リセット時刻到達。更新して確認してください',
+2
View File
@@ -459,6 +459,8 @@ export const en: Record<MessageKey, string> = {
'quota.fetch': 'Fetch Quota',
'quota.querying': 'Querying',
'quota.resetCredits': 'Manual resets',
'quota.creditBalance': 'Credit balance',
'quota.creditUnlimited': 'Unlimited',
'quota.earliestExpiry': 'Earliest expiry',
'quota.remaining': '{percent}% remaining',
'quota.resetPassed': 'Reset time reached; refresh to verify',
+2
View File
@@ -458,6 +458,8 @@ export const zhCN = {
'quota.fetch': '获取额度',
'quota.querying': '查询中',
'quota.resetCredits': '主动重置次数',
'quota.creditBalance': 'Credit 余额',
'quota.creditUnlimited': '无限额',
'quota.earliestExpiry': '最早过期',
'quota.remaining': '剩余 {percent}%',
'quota.resetPassed': '重置时间已到,请刷新确认',
+1
View File
@@ -208,6 +208,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
<div className="quota-card-error"><AlertCircle size={18} />{t('authFiles.quota.failed')}</div>
</> : null}
{quota.status === 'success' && provider === 'codex' ? <div className="quota-reset-credit-summary">
{(quota.creditsUnlimited || quota.creditBalance !== undefined) ? <span>{t('quota.creditBalance')} <strong>{quota.creditsUnlimited ? t('quota.creditUnlimited') : quota.creditBalance}</strong></span> : null}
<span>{t('quota.resetCredits')} <strong>{quota.resetCredits ?? '—'}</strong></span>
{quota.resetCreditsApplicable !== undefined ? <span>{t('quota.resetApplicable', { count: quota.resetCreditsApplicable })}</span> : null}
<span>{t('quota.earliestExpiry')} <strong>{formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale)}</strong></span>
+23
View File
@@ -32,6 +32,8 @@ export type QuotaState = {
rows: QuotaRow[];
error?: string;
plan?: string;
creditBalance?: string;
creditsUnlimited?: boolean;
resetCredits?: number;
resetCreditsApplicable?: number;
resetCreditsError?: string;
@@ -304,6 +306,24 @@ export const codexResetCreditsFor = (payload: unknown): number | undefined => {
return count === null ? undefined : Math.max(0, Math.floor(count));
};
/** Read the account-level credits returned by the Codex usage endpoint. */
export const codexAccountCreditsFor = (
payload: unknown,
): { balance?: string; unlimited: boolean } => {
const value = parseBody(payload);
if (!isRecord(value) || !isRecord(value.credits)) return { unlimited: false };
const rawBalance = value.credits.balance;
const balance = typeof rawBalance === 'number'
? String(rawBalance)
: typeof rawBalance === 'string' && rawBalance.trim() !== ''
? rawBalance.trim()
: undefined;
return {
...(balance && /^\d+(?:\.\d+)?$/.test(balance) && Number.isFinite(Number(balance)) ? { balance } : {}),
unlimited: value.credits.unlimited === true,
};
};
export const codexResetCreditDetailsFor = (
payload: unknown,
nowMs = Date.now(),
@@ -884,11 +904,14 @@ async function loadQuotaSnapshot(file: AuthFile): Promise<QuotaState> {
const usageCreditDetails = provider === 'codex' && isRecord(payload)
? codexResetCreditDetailsFor(payload.rate_limit_reset_credits ?? payload.rateLimitResetCredits)
: {};
const accountCredits = provider === 'codex' ? codexAccountCreditsFor(payload) : { unlimited: false };
return {
status: 'success',
rows,
plan: (provider === 'devin' ? readDevinQuota(payload).plan : detectedPlan)
?? (readString(isRecord(payload) ? payload : {}, 'plan_type', 'planType') || codexMetadata?.plan),
creditBalance: accountCredits.balance,
creditsUnlimited: accountCredits.unlimited,
subscriptionActiveUntil: provider === 'devin'
? readDevinQuota(payload).subscriptionActiveUntil : codexMetadata?.subscriptionActiveUntil,
resetCreditsError,
+2 -1
View File
@@ -5,6 +5,7 @@ import { consumeCodexResetCredit, loadQuota } from '../src/services/quotaService
const success = (body: unknown) => ({ status_code: 200, body });
const codexUsage = {
rate_limit: { primary_window: { used_percent: 25, limit_window_seconds: 18000 } },
credits: { balance: 12.5, unlimited: false },
rate_limit_reset_credits: { available_count: 2, applicable_available_count: 0 },
};
const codexFile = { name: 'codex-test.json', provider: 'codex', auth_index: 1 };
@@ -39,7 +40,7 @@ describe('quota API compatibility', () => {
...codexFile,
metadata: { id_token: { chatgpt_account_id: 'account-test', plan_type: 'pro', chatgpt_subscription_active_until: '2030-01-01T00:00:00Z' } },
});
expect(result).toMatchObject({ status: 'success', plan: 'pro', resetCredits: 2, resetCreditsApplicable: 0, resetCreditsError: 'credits forbidden' });
expect(result).toMatchObject({ status: 'success', plan: 'pro', creditBalance: '12.5', creditsUnlimited: false, resetCredits: 2, resetCreditsApplicable: 0, resetCreditsError: 'credits forbidden' });
expect(result.subscriptionActiveUntil).toBe('2030-01-01T00:00:00Z');
expect(calls).toHaveLength(2);
for (const call of calls) {
+15
View File
@@ -1,10 +1,25 @@
import { describe, expect, it } from 'bun:test';
import {
codexAccountCreditsFor,
codexResetCreditDetailsFor,
codexResetCreditsFor,
quotaRowsFor,
} from '../src/services/quotaService';
describe('Codex account credits', () => {
it('reads a numeric balance and unlimited flag from usage payloads', () => {
expect(codexAccountCreditsFor({ credits: { balance: 12.5, unlimited: false } }))
.toEqual({ balance: '12.5', unlimited: false });
expect(codexAccountCreditsFor({ credits: { unlimited: true } }))
.toEqual({ unlimited: true });
});
it('ignores malformed balances', () => {
expect(codexAccountCreditsFor({ credits: { balance: 'N/A', unlimited: false } }))
.toEqual({ unlimited: false });
});
});
describe('quotaRowsFor', () => {
it('把 Codex 的已用百分比转换为剩余额度', () => {
const rows = quotaRowsFor('codex', {