diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index b2f649c..f516742 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -1927,6 +1927,7 @@ export const jaOverrides = { 'quota.service.limit.numbered': '制限 {index}', 'quota.service.product.numbered': '製品 {index}', 'quota.service.monthlyIncluded': '月間内包クォータ', + 'quota.service.prepaidBalance': '前払い残高', 'quota.service.onDemand': '従量課金クォータ', 'quota.service.quota': 'クォータ', 'quota.service.quota.numbered': 'クォータ {index}', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 972dbe9..9e4bc98 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1927,6 +1927,7 @@ export const en: Record = { 'quota.service.limit.numbered': 'Limit {index}', 'quota.service.product.numbered': 'Product {index}', 'quota.service.monthlyIncluded': 'Monthly included quota', + 'quota.service.prepaidBalance': 'Prepaid balance', 'quota.service.onDemand': 'On-demand quota', 'quota.service.quota': 'Quota', 'quota.service.quota.numbered': 'Quota {index}', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index d828365..e3b2e72 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -1926,6 +1926,7 @@ export const zhCN = { 'quota.service.limit.numbered': '限制 {index}', 'quota.service.product.numbered': '产品 {index}', 'quota.service.monthlyIncluded': '月度包含额度', + 'quota.service.prepaidBalance': '预付余额', 'quota.service.onDemand': '按量付费额度', 'quota.service.quota': '配额', 'quota.service.quota.numbered': '配额 {index}', diff --git a/src/pages/AuthFileManagementPage.tsx b/src/pages/AuthFileManagementPage.tsx index 41667fc..dc3074a 100644 --- a/src/pages/AuthFileManagementPage.tsx +++ b/src/pages/AuthFileManagementPage.tsx @@ -1,3 +1,4 @@ +import { commitFetchedQuota } from '../services/quotaEnrichment'; import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useConfirmation } from '../components/ConfirmationDialog'; import { QuotaActionFeedback } from '../components/QuotaActionFeedback'; @@ -49,7 +50,6 @@ import { } from '../services/quotaService'; import { captureQuotaCacheGeneration, - commitQuotaCacheIfCurrent, getQuotaCacheSnapshot, pruneQuotaCache, updateQuotaCache, @@ -240,9 +240,7 @@ export function AuthFileManagementPage() { const cacheGeneration = captureQuotaCacheGeneration(); updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } })); const result = await loadQuota(file); - commitQuotaCacheIfCurrent(cacheGeneration, () => { - updateQuotaCache((current) => ({ ...current, [key]: result })); - }); + commitFetchedQuota(cacheGeneration, file, result); }; const refreshAllQuotas = async () => { diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index bd0b269..e631162 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -1,3 +1,4 @@ +import { commitFetchedQuota } from '../services/quotaEnrichment'; import { ResetCreditExpiries } from '../components/ResetCreditExpiries'; import { MessageNotice } from '../appNotice'; import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react'; @@ -28,7 +29,6 @@ import { } from '../services/quotaService'; import { captureQuotaCacheGeneration, - commitQuotaCacheIfCurrent, getQuotaCacheSnapshot, pruneQuotaCache, updateQuotaCache, @@ -96,9 +96,7 @@ export function QuotaPage() { const cacheGeneration = captureQuotaCacheGeneration(); updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } })); const result = await loadQuota(file); - commitQuotaCacheIfCurrent(cacheGeneration, () => { - updateQuotaCache((current) => ({ ...current, [key]: result })); - }); + commitFetchedQuota(cacheGeneration, file, result); }, [t]); const resetQuota = useQuotaReset(askConfirmation, setError); @@ -119,9 +117,7 @@ export function QuotaPage() { const batch = files.slice(index, index + REFRESH_CONCURRENCY); await Promise.all(batch.map(async (file) => { const result = await loadQuota(file); - commitQuotaCacheIfCurrent(cacheGeneration, () => { - updateQuotaCache((current) => ({ ...current, [quotaKey(file)]: result })); - }); + commitFetchedQuota(cacheGeneration, file, result); })); } } finally { diff --git a/src/services/quotaEnrichment.ts b/src/services/quotaEnrichment.ts new file mode 100644 index 0000000..7dff9f0 --- /dev/null +++ b/src/services/quotaEnrichment.ts @@ -0,0 +1,17 @@ +import { enrichXaiQuotaPlan, providerForFile, quotaKey, type AuthFile, type QuotaState } from './quotaService'; +import { commitQuotaCacheIfCurrent, getQuotaCacheSnapshot, updateQuotaCache } from './quotaCache'; + +export function commitFetchedQuota(generation: number, file: AuthFile, quota: QuotaState) { + const key = quotaKey(file); + const committed = commitQuotaCacheIfCurrent(generation, () => { + updateQuotaCache((current) => ({ ...current, [key]: quota })); + }); + if (committed && quota.status === 'success' && providerForFile(file) === 'xai') { + void enrichXaiQuotaPlan(file, quota).then((enriched) => { + if (enriched === quota || getQuotaCacheSnapshot()[key] !== quota) return; + commitQuotaCacheIfCurrent(generation, () => { + updateQuotaCache((current) => ({ ...current, [key]: enriched })); + }); + }).catch(() => { /* Optional plan failures preserve the original quota. */ }); + } +} diff --git a/src/services/quotaService.ts b/src/services/quotaService.ts index 4bbbdb6..3c479fd 100644 --- a/src/services/quotaService.ts +++ b/src/services/quotaService.ts @@ -527,8 +527,8 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo reset: absoluteResetLabel(billing.periodEnd), resetAtMs: billing.resetAtMs ?? undefined, }; - if (billing.periodType === 'weekly' - && (billing.usagePercent !== null || billing.periodEnd || billing.productUsage.length > 0)) { + const hasWeeklyData = billing.periodType === 'weekly'; + if (hasWeeklyData) { rows.push({ label: quotaText('quota.service.weekly'), remainingPercent: remainingFromUsedPercent(billing.usagePercent), @@ -555,7 +555,8 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo detail: amount(billing.onDemandCapCents, billing.onDemandUsedCents), }); } - if (billing.monthlyLimitCents !== null || billing.usedCents !== null || billing.billingPeriodEnd) { + if ((billing.monthlyLimitCents !== null || billing.usedCents !== null || billing.billingPeriodEnd) + && !(hasWeeklyData && billing.monthlyLimitCents === 0 && billing.usedCents === 0)) { rows.push({ label: quotaText('quota.service.monthlyIncluded'), remainingPercent: remainingFromUsedPercent(billing.usedPercent), @@ -563,6 +564,10 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo ...monthlyReset, }); } + if (billing.prepaidBalanceCents !== null && billing.prepaidBalanceCents > 0) { + rows.push({ label: quotaText('quota.service.prepaidBalance'), remainingPercent: null, + detail: formatUsdFromCents(billing.prepaidBalanceCents) }); + } return rows.length > 0 ? rows : [{ label: quotaText(billing.periodType === 'weekly' ? 'quota.service.weekly' : 'quota.service.monthlyIncluded'), @@ -709,6 +714,43 @@ const callXaiQuota = async (file: AuthFile): Promise => { } }; +// Optional subscription requests run after the quota has been committed to the shared cache. +export const enrichXaiQuotaPlan = async (file: AuthFile, quota: QuotaState): Promise => { + if (providerForFile(file) !== 'xai' || quota.status !== 'success') return quota; + const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex); + if (!authIndex) return quota; + const header = { ...headersByProvider.xai }; + const userId = resolveXaiUserId(file); + if (userId) header['x-userid'] = userId; + const [user, settings] = await Promise.allSettled([ + requestQuotaPayload(authIndex, 'https://cli-chat-proxy.grok.com/v1/user?include=subscription', header, 'GET', undefined, 8_000), + requestQuotaPayload(authIndex, 'https://cli-chat-proxy.grok.com/v1/settings', header, 'GET', undefined, 8_000), + ]); + const readPlan = (result: PromiseSettledResult, keys: string[]) => { + if (result.status !== 'fulfilled' || !isRecord(result.value)) return ''; + for (const key of keys) { + const value = result.value[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; + }; + const plan = readPlan(settings, ['subscription_tier_display', 'subscriptionTierDisplay']) + || readPlan(user, ['subscriptionTier', 'subscription_tier']); + return plan ? { ...quota, plan } : quota; +}; + +const xaiBillingPlan = (payload: unknown): string | undefined => { + if (!isRecord(payload)) return undefined; + const configs = [payload.monthly, payload.weekly, payload].filter(isRecord) + .map((record) => isRecord(record.config) ? record.config : record); + for (const config of configs) { + const limit = buildXaiBillingSummary(config as XaiBillingConfig)?.monthlyLimitCents; + if (limit === 150_000) return 'SuperGrok Heavy'; + if (limit === 15_000) return 'SuperGrok'; + } + return undefined; +}; + const booleanValue = (value: unknown): boolean | null => { if (typeof value === 'boolean') return value; if (typeof value === 'number') return Number.isFinite(value) ? value !== 0 : null; @@ -923,6 +965,7 @@ async function loadQuotaSnapshot(file: AuthFile): Promise { status: 'success', rows, plan: (provider === 'devin' ? readDevinQuota(payload).plan : detectedPlan) + ?? (provider === 'xai' ? xaiBillingPlan(payload) : undefined) ?? (readString(isRecord(payload) ? payload : {}, 'plan_type', 'planType') || codexMetadata?.plan), creditBalance: accountCredits.balance, creditsUnlimited: accountCredits.unlimited, diff --git a/src/services/xaiBilling.ts b/src/services/xaiBilling.ts index 96170bb..4352cbe 100644 --- a/src/services/xaiBilling.ts +++ b/src/services/xaiBilling.ts @@ -24,6 +24,8 @@ export interface XaiBillingConfig { monthlyLimit?: XaiBillingCent | number | string | null; monthly_limit?: XaiBillingCent | number | string | null; used?: XaiBillingCent | number | string | null; + prepaidBalance?: XaiBillingCent | number | string | null; + prepaid_balance?: XaiBillingCent | number | string | null; onDemandCap?: XaiBillingCent | number | string | null; on_demand_cap?: XaiBillingCent | number | string | null; onDemandUsed?: XaiBillingCent | number | string | null; @@ -59,6 +61,7 @@ export interface XaiBillingSummary { productUsage: XaiProductUsageSummary[]; monthlyLimitCents: number | null; usedCents: number | null; + prepaidBalanceCents: number | null; includedUsedCents: number | null; onDemandCapCents: number | null; onDemandUsedCents: number | null; @@ -154,6 +157,7 @@ const emptyXaiBillingSummary = (): XaiBillingSummary => ({ productUsage: [], monthlyLimitCents: null, usedCents: null, + prepaidBalanceCents: null, includedUsedCents: null, onDemandCapCents: null, onDemandUsedCents: null, @@ -200,6 +204,7 @@ export function buildXaiBillingSummary( const monthlyLimitCents = normalizeXaiCentValue(config.monthlyLimit ?? config.monthly_limit); const usedCents = normalizeXaiCentValue(config.used); + const prepaidBalanceCents = normalizeXaiCentValue(config.prepaidBalance ?? config.prepaid_balance); const onDemandCapCents = normalizeXaiCentValue(config.onDemandCap ?? config.on_demand_cap); const explicitOnDemandUsedCents = normalizeXaiCentValue( config.onDemandUsed ?? config.on_demand_used @@ -236,7 +241,7 @@ export function buildXaiBillingSummary( usedCents !== null || (!hasWeeklyData && (onDemandCapCents !== null || !!billingPeriodEnd)); - if (!hasWeeklyData && !hasMonthlyData) return null; + if (!hasWeeklyData && !hasMonthlyData && prepaidBalanceCents === null) return null; summary.periodType = hasWeeklyData ? periodType === 'unknown' @@ -249,6 +254,7 @@ export function buildXaiBillingSummary( summary.productUsage = productUsage; summary.monthlyLimitCents = monthlyLimitCents; summary.usedCents = usedCents; + summary.prepaidBalanceCents = prepaidBalanceCents; summary.includedUsedCents = includedUsedCents; summary.onDemandCapCents = onDemandCapCents; summary.onDemandUsedCents = onDemandUsedCents; @@ -285,7 +291,8 @@ export function mergeXaiBillingSummaries( mode: 'billing', source: 'cli-chat-proxy', periodType: periodSummary.periodType, - usagePercent: primary.usagePercent ?? fallback.usagePercent, + // Usage belongs to the same period as its dates; monthly spending cannot fill a weekly gap. + usagePercent: periodSummary.usagePercent, periodStart, periodEnd, resetAtMs: periodInstants.resetAtMs, @@ -293,6 +300,7 @@ export function mergeXaiBillingSummaries( productUsage: primary.productUsage.length > 0 ? primary.productUsage : fallback.productUsage, monthlyLimitCents: primary.monthlyLimitCents ?? fallback.monthlyLimitCents, usedCents: primary.usedCents ?? fallback.usedCents, + prepaidBalanceCents: primary.prepaidBalanceCents ?? fallback.prepaidBalanceCents, includedUsedCents: primary.includedUsedCents ?? fallback.includedUsedCents, onDemandCapCents: primary.onDemandCapCents ?? fallback.onDemandCapCents, onDemandUsedCents: primary.onDemandUsedCents ?? fallback.onDemandUsedCents, diff --git a/tests/fixtures/grok-quota/index.html b/tests/fixtures/grok-quota/index.html new file mode 100644 index 0000000..f15c075 --- /dev/null +++ b/tests/fixtures/grok-quota/index.html @@ -0,0 +1,2 @@ + +
diff --git a/tests/fixtures/grok-quota/main.tsx b/tests/fixtures/grok-quota/main.tsx new file mode 100644 index 0000000..40f03f9 --- /dev/null +++ b/tests/fixtures/grok-quota/main.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import { I18nProvider } from '../../../src/i18n'; +import { QuotaPage } from '../../../src/pages/QuotaPage'; +import '../../../src/styles/index.css'; + +// Fictional billing and subscription responses; no upstream calls or real credentials. +const params = new URLSearchParams(location.search); +localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-CN'); +document.documentElement.dataset.theme = params.get('theme') || 'light'; +mockIPC(async (command, args) => { + if (command !== 'management_request') throw new Error('Unexpected fixture command'); + const request = args.request as { path: string; body?: { url: string } }; + if (request.path === '/credentials') return { files: [{ name: 'grok-fictional.json', provider: 'xai', auth_index: 'fixture' }] }; + const url = request.body?.url || ''; + if (url.endsWith('/settings') || url.includes('/user?')) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + return { status_code: 200, body: { subscription_tier_display: 'SuperGrok Heavy' } }; + } + return { status_code: 200, body: { config: url.includes('format=credits') + ? { currentPeriod: { type: 'weekly', end: '2030-01-08T00:00:00Z' }, prepaidBalance: { val: 250 } } + : { monthlyLimit: 15000, used: 3000, billingPeriodEnd: '2030-02-01T00:00:00Z' } } }; +}); +createRoot(document.getElementById('root')!).render(); diff --git a/tests/quotaEnrichment.test.ts b/tests/quotaEnrichment.test.ts new file mode 100644 index 0000000..c52112c --- /dev/null +++ b/tests/quotaEnrichment.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, it, spyOn } from 'bun:test'; +import { managementApi } from '../src/services/managementApi'; +import { commitFetchedQuota } from '../src/services/quotaEnrichment'; +import { captureQuotaCacheGeneration, getQuotaCacheSnapshot, pruneQuotaCache, updateQuotaCache } from '../src/services/quotaCache'; +import { enrichXaiQuotaPlan, loadQuota, quotaKey, type QuotaState } from '../src/services/quotaService'; + +const file = { name: 'grok-fixture.json', provider: 'xai', auth_index: 'fixture', sub: 'fictional-user' }; +const key = quotaKey(file); +const quota: QuotaState = { status: 'success', rows: [{ label: 'Weekly', remainingPercent: 75 }], plan: 'Paid' }; +let post: ReturnType | undefined; +afterEach(() => { post?.mockRestore(); updateQuotaCache({}); }); +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +it('先提交额度,再后台补套餐;两次请求使用短超时和现有用户头', async () => { + let release!: (value: unknown) => void; + const gate = new Promise((resolve) => { release = resolve; }); + post = spyOn(managementApi, 'post').mockImplementation(async (path, body, options) => { + expect(path).toBe('/requests/api-call'); + expect(getQuotaCacheSnapshot()[key]).toBe(quota); + expect(options).toEqual({ timeoutMs: 8000 }); + expect(body).toMatchObject({ authIndex: 'fixture', method: 'GET', header: { 'x-userid': 'fictional-user' } }); + return await gate as never; + }); + commitFetchedQuota(captureQuotaCacheGeneration(), file, quota); + expect(getQuotaCacheSnapshot()[key]).toBe(quota); + expect(post).toHaveBeenCalledTimes(2); + release({ status_code: 200, body: { subscription_tier_display: 'SuperGrok Heavy' } }); + await flush(); + expect(getQuotaCacheSnapshot()[key]).toEqual({ ...quota, plan: 'SuperGrok Heavy' }); +}); + +it('settings 失败仍可使用 user 套餐,两个失败保留原结果', async () => { + post = spyOn(managementApi, 'post').mockImplementation(async (_path, body) => { + const request = body as { url: string }; + if (request.url.endsWith('/settings')) throw new Error('offline'); + return { status_code: 200, body: JSON.stringify({ subscriptionTier: 'SuperGrok' }) } as never; + }); + expect(await enrichXaiQuotaPlan(file, quota)).toEqual({ ...quota, plan: 'SuperGrok' }); + post.mockImplementation(async () => ({ status_code: 403, body: 'unavailable' }) as never); + expect(await enrichXaiQuotaPlan(file, quota)).toBe(quota); + post.mockImplementation(async () => ({ status_code: 200, body: { + subscription_tier_display: { invalid: true }, subscriptionTier: false, + } }) as never); + expect(await enrichXaiQuotaPlan(file, quota)).toBe(quota); +}); + +for (const change of ['refresh', 'remove', 'session'] as const) { + it(`迟到套餐不覆盖 ${change}`, async () => { + let release!: (value: unknown) => void; + const gate = new Promise((resolve) => { release = resolve; }); + post = spyOn(managementApi, 'post').mockImplementation(async () => await gate as never); + commitFetchedQuota(captureQuotaCacheGeneration(), file, quota); + if (change === 'refresh') updateQuotaCache({ [key]: { status: 'loading', rows: [] } }); + else if (change === 'remove') updateQuotaCache({}); + else { pruneQuotaCache(new Set()); updateQuotaCache({ [key]: quota }); } + const expected = getQuotaCacheSnapshot(); + release({ status_code: 200, body: { subscription_tier_display: 'SuperGrok Heavy' } }); + await flush(); + expect(getQuotaCacheSnapshot()).toBe(expected); + }); +} + +it('套餐接口不阻塞账单结果,并按月额度提供套餐回退', async () => { + post = spyOn(managementApi, 'post').mockImplementation(async (_path, body) => { + expect((body as { url: string }).url).toContain('/billing'); + return { status_code: 200, body: { config: { monthlyLimit: 15000, used: 3000 } } } as never; + }); + expect(await loadQuota(file)).toMatchObject({ status: 'success', plan: 'SuperGrok' }); + expect(post).toHaveBeenCalledTimes(2); +}); diff --git a/tests/quotaRendering.test.tsx b/tests/quotaRendering.test.tsx index 6c553c0..1025704 100644 --- a/tests/quotaRendering.test.tsx +++ b/tests/quotaRendering.test.tsx @@ -55,6 +55,18 @@ describe('quota card rendering', () => { expect(full).toContain('width:100%'); expect(full).not.toContain('disabled=""'); }); + it('Grok 套餐、未知周额度和预付余额可通过现有卡片完整显示', () => { + const html = render({ status: 'success', plan: 'SuperGrok Heavy', rows: quotaRowsFor('xai', { + weekly: { config: { currentPeriod: { type: 'weekly', end: '2030-01-08T00:00:00Z' }, prepaidBalance: 250 } }, + monthly: { config: { monthlyLimit: 10000, used: 2500 } }, + }) }, 'xai'); + expect(html).toContain('SuperGrok Heavy'); + expect(html).toContain('Weekly quota'); + expect(html).toContain('Prepaid balance'); + expect(html).toContain('$2.50'); + expect(html.match(/75% remaining/g)).toHaveLength(1); + expect(html.match(/real-quota-track/g)).toHaveLength(1); + }); it('缓存中的原始重置时间提供动态提示,过期额度不伪造为已恢复', () => { const html = render({ status: 'success', rows: [{ label: '5h', remainingPercent: 0, resetAtMs: Date.parse('2020-01-01T00:00:00Z') }] }); expect(html).toContain('Reset time reached; refresh to verify'); diff --git a/tests/quotaRequests.test.ts b/tests/quotaRequests.test.ts index 7e15ef1..b850173 100644 --- a/tests/quotaRequests.test.ts +++ b/tests/quotaRequests.test.ts @@ -184,7 +184,7 @@ describe('xAI quota queries aligned with Management Center', () => { handler = (request) => success({ config: request.url.includes('format=credits') ? { currentPeriod: { type: 'weekly' } } : { creditUsagePercent: 0 } }); expect(await loadQuota(file)).toMatchObject({ - status: 'success', rows: [{ remainingPercent: 100 }], + status: 'success', rows: [{ remainingPercent: null }], }); expect(calls).toHaveLength(2); }); diff --git a/tests/quotaService.test.ts b/tests/quotaService.test.ts index 70cf398..889ca51 100644 --- a/tests/quotaService.test.ts +++ b/tests/quotaService.test.ts @@ -335,7 +335,32 @@ describe('quotaRowsFor', () => { monthly: { config: { creditUsagePercent: 0, currentPeriod: { type: 'monthly', end: '2030-01-01T00:00:00Z' } } }, }); expect(rows).toHaveLength(1); - expect(rows[0].remainingPercent).toBe(100); + expect(rows[0].remainingPercent).toBeNull(); expect(rows[0].resetAtMs).toBeUndefined(); }); + it('xAI 周用量缺失或无效时不会借用月度支出百分比', () => { + for (const creditUsagePercent of [undefined, 'bad']) { + for (const used of [0, 2500]) { + const rows = quotaRowsFor('xai', { + weekly: { config: { currentPeriod: { type: 'weekly', end: '2030-01-08T00:00:00Z' }, creditUsagePercent } }, + monthly: { config: { monthlyLimit: 10000, used } }, + }); + expect(rows[0]).toMatchObject({ label: 'Weekly quota', remainingPercent: null }); + expect(rows[1].remainingPercent).toBe(100 - used / 100); + } + } + }); + it('xAI 隐藏周额度下的零预算零用量月行,但保留真实支出和月度独立行', () => { + const weekly = { config: { creditUsagePercent: 25 } }; + expect(quotaRowsFor('xai', { weekly, monthly: { config: { monthlyLimit: 0, used: 0 } } })).toHaveLength(1); + expect(quotaRowsFor('xai', { weekly, monthly: { config: { monthlyLimit: 0, used: 100 } } })).toHaveLength(2); + expect(quotaRowsFor('xai', { config: { monthlyLimit: 0, used: 0 } })).toHaveLength(1); + }); + it('xAI 支持两种预付余额字段,金额不当作百分比', () => { + for (const key of ['prepaidBalance', 'prepaid_balance']) { + expect(quotaRowsFor('xai', { config: { [key]: { val: 250 } } })).toEqual([ + { label: 'Prepaid balance', remainingPercent: null, detail: '$2.50' }, + ]); + } + }); });