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:
1 parent
a79140b8d2
commit
2b81856521
8 files changed
+54
-3
No files matched your search
@@ -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} />
|
||||
|
||||
@@ -472,6 +472,8 @@ export const jaOverrides = {
|
||||
'quota.fetch': 'クォータを取得',
|
||||
'quota.querying': '照会中',
|
||||
'quota.resetCredits': '手動リセット回数',
|
||||
'quota.creditBalance': 'クレジット残高',
|
||||
'quota.creditUnlimited': '無制限',
|
||||
'quota.earliestExpiry': '最も早い有効期限',
|
||||
'quota.remaining': '残り {percent}%',
|
||||
'quota.resetPassed': 'リセット時刻到達。更新して確認してください',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '重置时间已到,请刷新确认',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in new issue
Block a user