diff --git a/src/components/AuthFileQuotaPanel.tsx b/src/components/AuthFileQuotaPanel.tsx index e229379..f8e56dd 100644 --- a/src/components/AuthFileQuotaPanel.tsx +++ b/src/components/AuthFileQuotaPanel.tsx @@ -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) ?
{!compactLayout ? {t('authFiles.settings.quotaRemaining')} : null} {!compactLayout && quota.plan ? {quota.plan} : null} + {dense && resetCreditsLabel ? {resetCreditsLabel} : null} {showReset ? : null} - {dense && !showReset && resetCreditsLabel ? {resetCreditsLabel} : null} + title={t('quota.reset')}>{t('quota.reset')} : null} {!compactLayout ?
: null} diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 68e11c2..a9f070d 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -472,6 +472,8 @@ export const jaOverrides = { 'quota.fetch': 'クォータを取得', 'quota.querying': '照会中', 'quota.resetCredits': '手動リセット回数', + 'quota.creditBalance': 'クレジット残高', + 'quota.creditUnlimited': '無制限', 'quota.earliestExpiry': '最も早い有効期限', 'quota.remaining': '残り {percent}%', 'quota.resetPassed': 'リセット時刻到達。更新して確認してください', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4d66f61..58c93b3 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -459,6 +459,8 @@ export const en: Record = { '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', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 0a652f8..5a030a5 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -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': '重置时间已到,请刷新确认', diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index 9b24f26..04bc4d8 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -208,6 +208,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
{t('authFiles.quota.failed')}
: null} {quota.status === 'success' && provider === 'codex' ?
+ {(quota.creditsUnlimited || quota.creditBalance !== undefined) ? {t('quota.creditBalance')} {quota.creditsUnlimited ? t('quota.creditUnlimited') : quota.creditBalance} : null} {t('quota.resetCredits')} {quota.resetCredits ?? '—'} {quota.resetCreditsApplicable !== undefined ? {t('quota.resetApplicable', { count: quota.resetCreditsApplicable })} : null} {t('quota.earliestExpiry')} {formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale)} diff --git a/src/services/quotaService.ts b/src/services/quotaService.ts index 85bfc51..eca098f 100644 --- a/src/services/quotaService.ts +++ b/src/services/quotaService.ts @@ -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 { 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, diff --git a/tests/quotaRequests.test.ts b/tests/quotaRequests.test.ts index 1626423..b7dc954 100644 --- a/tests/quotaRequests.test.ts +++ b/tests/quotaRequests.test.ts @@ -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) { diff --git a/tests/quotaService.test.ts b/tests/quotaService.test.ts index dca8a0b..604b4c0 100644 --- a/tests/quotaService.test.ts +++ b/tests/quotaService.test.ts @@ -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', {