diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index c3888f8..a557b83 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -192,6 +192,7 @@ export const jaOverrides = { 'quota.unknownProvider': '不明', 'common.confirm': '確認', 'quota.service.xaiPaidAccount': '有料 API アカウント', + 'quota.service.xaiPaidHealth': '有料 API の会話が利用可能です。xAI は現在、この OAuth 認証情報の総クォータを提供していません。', 'quota.service.xaiPaidQuotaUnavailable': 'このアカウントの残りクォータは提供されていません。', 'quota.confirm.button': 'リセットを実行', 'quota.confirm.applicableLabel': '現在適用可能な回数', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 6665b83..b8a32a6 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -179,6 +179,7 @@ export const en: Record = { 'quota.unknownProvider': 'Unknown', 'common.confirm': 'Confirm', 'quota.service.xaiPaidAccount': 'Paid API account', + 'quota.service.xaiPaidHealth': 'Paid API chat is available. xAI does not currently provide total quota data for this OAuth credential.', 'quota.service.xaiPaidQuotaUnavailable': 'Remaining quota is not provided for this account.', 'quota.confirm.button': 'Confirm Reset', 'quota.confirm.applicableLabel': 'Currently applicable resets', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index ca04983..53ed78d 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -178,6 +178,7 @@ export const zhCN = { 'quota.unknownProvider': '未知', 'common.confirm': '确认', 'quota.service.xaiPaidAccount': '付费 API 账号', + 'quota.service.xaiPaidHealth': '付费 API 对话可用。xAI 暂不为此 OAuth 凭证提供额度总量数据。', 'quota.service.xaiPaidQuotaUnavailable': '此账号不提供剩余额度。', 'quota.confirm.button': '确认重置', 'quota.confirm.applicableLabel': '当前适用次数', diff --git a/src/services/quotaService.ts b/src/services/quotaService.ts index 4fbf68a..78479d3 100644 --- a/src/services/quotaService.ts +++ b/src/services/quotaService.ts @@ -1,3 +1,4 @@ +import { buildXaiBillingSummary, mergeXaiBillingSummaries, type XaiBillingConfig } from './xaiBilling'; import { apiCallErrorMessage, isRecord, @@ -466,102 +467,69 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo } if (provider === 'xai') { - if (value.mode === 'paid-info') return [{ + if (value.mode === 'paid-health' || value.mode === 'paid-info') return [{ label: quotaText('quota.service.xaiPaidAccount'), remainingPercent: null, - detail: quotaText('quota.service.xaiPaidQuotaUnavailable'), + detail: quotaText(value.mode === 'paid-health' + ? 'quota.service.xaiPaidHealth' : 'quota.service.xaiPaidQuotaUnavailable'), }]; - const payloads = isRecord(value.weekly) || isRecord(value.monthly) - ? [value.weekly, value.monthly] - : [value]; + const build = (payload: unknown) => { + if (!isRecord(payload)) return null; + return buildXaiBillingSummary( + (isRecord(payload.config) ? payload.config : payload) as XaiBillingConfig, + ); + }; + const billing = isRecord(value.weekly) || isRecord(value.monthly) + ? mergeXaiBillingSummaries(build(value.weekly), build(value.monthly)) + : build(value); + if (!billing) return []; const rows: QuotaRow[] = []; - - payloads.forEach((payload) => { - if (!isRecord(payload)) return; - const config = isRecord(payload.config) ? payload.config : payload; - const currentPeriod = isRecord(config.current_period) - ? config.current_period - : isRecord(config.currentPeriod) - ? config.currentPeriod - : null; - const periodType = readString(currentPeriod, 'type').toLowerCase(); - const weeklyUsed = numberValue(config.credit_usage_percent ?? config.creditUsagePercent); - - const productItems = config.product_usage ?? config.productUsage; - if (weeklyUsed !== null || periodType.includes('week') || (Array.isArray(productItems) && productItems.length > 0)) { - rows.push({ - label: quotaText('quota.service.weekly'), - remainingPercent: remainingFromUsedPercent(weeklyUsed), - reset: absoluteResetLabel(currentPeriod?.end), - resetAtMs: currentPeriod ? quotaResetFor(currentPeriod, ['end']) : undefined, - }); - const productUsage = Array.isArray(config.product_usage) - ? config.product_usage - : Array.isArray(config.productUsage) - ? config.productUsage - : []; - productUsage.forEach((item, index) => { - if (!isRecord(item)) return; - rows.push({ - label: readString(item, 'product') - || quotaText('quota.service.product.numbered', { index: index + 1 }), - remainingPercent: remainingFromUsedPercent( - item.usage_percent ?? item.usagePercent, - ), - reset: absoluteResetLabel(currentPeriod?.end), - resetAtMs: currentPeriod ? quotaResetFor(currentPeriod, ['end']) : undefined, - }); - }); - } - - const limit = numberValue(config.monthly_limit ?? config.monthlyLimit); - const used = numberValue(config.used); - const includedUsed = used === null - ? null - : limit !== null && limit > 0 - ? Math.min(used, limit) - : used; - if (limit !== null || used !== null) { - const remaining = limit !== null && limit > 0 && includedUsed !== null - ? ((limit - includedUsed) / limit) * 100 - : null; - rows.push({ - label: quotaText('quota.service.monthlyIncluded'), - remainingPercent: clampPercent(remaining), - reset: absoluteResetLabel(config.billing_period_end ?? config.billingPeriodEnd), - resetAtMs: quotaResetFor(config, ['billing_period_end', 'billingPeriodEnd']), - detail: limit !== null - ? `${formatUsdFromCents(Math.max(0, limit - (includedUsed ?? 0)))} / ${formatUsdFromCents(limit)}` - : undefined, - }); - } - - const onDemandCap = numberValue(config.on_demand_cap ?? config.onDemandCap); - const explicitOnDemandUsed = numberValue(config.on_demand_used ?? config.onDemandUsed); - const onDemandUsed = explicitOnDemandUsed - ?? (used !== null && limit !== null ? Math.max(0, used - limit) : null); - if (onDemandCap !== null && onDemandCap > 0) { - rows.push({ - label: quotaText('quota.service.onDemand'), - remainingPercent: clampPercent( - onDemandUsed === null ? null : ((onDemandCap - onDemandUsed) / onDemandCap) * 100, - ), - reset: absoluteResetLabel(config.billing_period_end ?? config.billingPeriodEnd), - resetAtMs: quotaResetFor(config, ['billing_period_end', 'billingPeriodEnd']), - detail: `${formatUsdFromCents(Math.max(0, onDemandCap - (onDemandUsed ?? 0)))} / ${formatUsdFromCents(onDemandCap)}`, - }); - } - }); - - const seen = new Set(); - return rows.filter((row) => { - const key = `${row.label}::${row.reset ?? ''}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); + const weeklyReset = { + reset: absoluteResetLabel(billing.periodEnd), + resetAtMs: billing.resetAtMs ?? undefined, + }; + if (billing.periodType === 'weekly' + && (billing.usagePercent !== null || billing.periodEnd || billing.productUsage.length > 0)) { + rows.push({ + label: quotaText('quota.service.weekly'), + remainingPercent: remainingFromUsedPercent(billing.usagePercent), + ...weeklyReset, + }); + } + billing.productUsage.forEach((item) => rows.push({ + label: item.product, + remainingPercent: remainingFromUsedPercent(item.usagePercent), + })); + const monthlyReset = { + reset: absoluteResetLabel(billing.billingPeriodEnd), + resetAtMs: quotaResetInstant(billing.billingPeriodEnd), + }; + const amount = (cap: number | null, used: number | null) => { + const remaining = cap !== null && used !== null ? Math.max(0, cap - used) : null; + return cap === null ? formatUsdFromCents(remaining) + : `${formatUsdFromCents(remaining)} / ${formatUsdFromCents(cap)}`; + }; + if (billing.onDemandCapCents !== null && billing.onDemandCapCents > 0) { + rows.push({ + label: quotaText('quota.service.onDemand'), + remainingPercent: remainingFromUsedPercent(billing.onDemandUsedPercent), + detail: amount(billing.onDemandCapCents, billing.onDemandUsedCents), + }); + } + if (billing.monthlyLimitCents !== null || billing.usedCents !== null || billing.billingPeriodEnd) { + rows.push({ + label: quotaText('quota.service.monthlyIncluded'), + remainingPercent: remainingFromUsedPercent(billing.usedPercent), + detail: amount(billing.monthlyLimitCents, billing.includedUsedCents), + ...monthlyReset, + }); + } + return rows.length > 0 ? rows : [{ + label: quotaText(billing.periodType === 'weekly' + ? 'quota.service.weekly' : 'quota.service.monthlyIncluded'), + remainingPercent: null, + }]; } - const nested = parseBody(value.body); const summary = !Array.isArray(value.groups) && isRecord(nested) ? nested : value; const groups = Array.isArray(summary.groups) ? summary.groups : []; @@ -656,10 +624,31 @@ const requestQuotaPayload = async ( return parseBody(response.body ?? response.bodyText); }; +const callXaiPaidHealth = async (authIndex: string): Promise => { + const header = { Authorization: 'Bearer $TOKEN$', accept: 'application/json' }; + const [profile, chat] = await Promise.allSettled([ + requestQuotaPayload(authIndex, 'https://api.x.ai/v1/me', header, 'GET', undefined, 15_000), + requestQuotaPayload(authIndex, 'https://api.x.ai/v1/chat/completions', { + ...header, 'Content-Type': 'application/json', + }, 'POST', JSON.stringify({ + model: 'grok-4.5', + messages: [{ role: 'user', content: 'ping' }], + max_tokens: 1, + stream: false, + }), 15_000), + ]); + if (chat.status === 'rejected') throw chat.reason; + const record = profile.status === 'fulfilled' && isRecord(profile.value) ? profile.value : {}; + return { + mode: 'paid-health', plan_type: 'Paid', + userId: readString(record, 'user_id', 'userId') || undefined, + teamId: readString(record, 'team_id', 'teamId') || undefined, + }; +}; const callXaiQuota = async (file: AuthFile): Promise => { const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex); if (!authIndex) throw new Error(quotaText('quota.service.error.missingAuthIndex')); - if (isPaidXaiFile(file)) return { mode: 'paid-info', plan_type: 'Paid' }; + if (isPaidXaiFile(file)) return callXaiPaidHealth(authIndex); const header = { ...headersByProvider.xai }; const userId = await resolveXaiUserId(file); if (userId) header['x-userid'] = userId; @@ -674,7 +663,11 @@ const callXaiQuota = async (file: AuthFile): Promise => { if (quotaRowsFor('xai', payload).length > 0) return payload; const billingError = weekly.status === 'rejected' && monthly.status === 'rejected' ? weekly.reason : new Error(quotaText('quota.service.error.unrecognized')); - throw billingError; + try { + return await callXaiPaidHealth(authIndex); + } catch { + throw billingError; + } }; const booleanValue = (value: unknown): boolean | null => { diff --git a/src/services/xaiBilling.ts b/src/services/xaiBilling.ts new file mode 100644 index 0000000..96170bb --- /dev/null +++ b/src/services/xaiBilling.ts @@ -0,0 +1,304 @@ +export interface XaiBillingCent { + val?: number | string; +} + +export interface XaiBillingPeriod { + type?: string; + start?: string; + end?: string; +} + +export interface XaiBillingProductUsage { + product?: string; + usagePercent?: number | string | null; + usage_percent?: number | string | null; +} + +export interface XaiBillingConfig { + currentPeriod?: XaiBillingPeriod | null; + current_period?: XaiBillingPeriod | null; + creditUsagePercent?: number | string | null; + credit_usage_percent?: number | string | null; + productUsage?: XaiBillingProductUsage[] | null; + product_usage?: XaiBillingProductUsage[] | null; + monthlyLimit?: XaiBillingCent | number | string | null; + monthly_limit?: XaiBillingCent | number | string | null; + used?: XaiBillingCent | number | string | null; + onDemandCap?: XaiBillingCent | number | string | null; + on_demand_cap?: XaiBillingCent | number | string | null; + onDemandUsed?: XaiBillingCent | number | string | null; + on_demand_used?: XaiBillingCent | number | string | null; + billingPeriodStart?: string; + billing_period_start?: string; + billingPeriodEnd?: string; + billing_period_end?: string; +} + +export interface XaiBillingPayload { + config?: XaiBillingConfig | null; +} + +export type XaiBillingPeriodType = 'weekly' | 'monthly' | 'unknown'; + +export interface XaiProductUsageSummary { + product: string; + usagePercent: number | null; +} + +export interface XaiBillingSummary { + mode: 'billing' | 'paid-health'; + source?: 'cli-chat-proxy' | 'api.x.ai-fallback'; + planType?: 'paid'; + healthStatus?: 'chat-ok'; + userId?: string; + teamId?: string; + periodType: XaiBillingPeriodType; + usagePercent: number | null; + periodStart?: string; + periodEnd?: string; + productUsage: XaiProductUsageSummary[]; + monthlyLimitCents: number | null; + usedCents: number | null; + includedUsedCents: number | null; + onDemandCapCents: number | null; + onDemandUsedCents: number | null; + onDemandUsedPercent: number | null; + billingPeriodStart?: string; + billingPeriodEnd?: string; + usedPercent: number | null; + resetAtMs?: number | null; + periodHours?: number | null; +} + +export function normalizeStringValue(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return value.toString(); + } + return null; +} + +export function normalizeNumberValue(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +export function parseIsoToMs(value: unknown): number | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const normalized = trimmed.replace(/(\.\d{6})\d+/, '$1'); + const ms = new Date(normalized).getTime(); + return Number.isFinite(ms) ? ms : null; +} +export function parseUnixToMs(value: unknown): number | null { + const numeric = + typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN; + if (!Number.isFinite(numeric) || numeric <= 0) return null; + return numeric < 1e11 ? numeric * 1000 : numeric; +} +export function resolveResetMs(candidates: readonly unknown[]): number | null { + for (const candidate of candidates) { + const iso = parseIsoToMs(candidate); + if (iso !== null) return iso; + const unix = parseUnixToMs(candidate); + if (unix !== null) return unix; + } + return null; +} +function normalizeXaiCentValue(value: XaiBillingConfig['monthlyLimit']): number | null { + if (value === undefined || value === null) return null; + if (typeof value === 'object' && !Array.isArray(value)) { + return normalizeNumberValue((value as { val?: unknown }).val); + } + return normalizeNumberValue(value); +} + +function resolveXaiPeriodType(period?: XaiBillingPeriod | null): XaiBillingPeriodType { + const rawType = normalizeStringValue(period?.type)?.toLowerCase() ?? ''; + if (rawType.includes('weekly')) return 'weekly'; + if (rawType.includes('monthly')) return 'monthly'; + return 'unknown'; +} + +function normalizeXaiProductUsage( + productUsage: XaiBillingConfig['productUsage'], + fallbackPrefix: string +): XaiProductUsageSummary[] { + if (!Array.isArray(productUsage)) return []; + + return productUsage + .map((item, index): XaiProductUsageSummary | null => { + if (!item || typeof item !== 'object') return null; + const product = normalizeStringValue(item.product) ?? `${fallbackPrefix} ${index + 1}`; + const usagePercent = normalizeNumberValue(item.usagePercent ?? item.usage_percent); + return { product, usagePercent }; + }) + .filter((item): item is XaiProductUsageSummary => item !== null); +} + +const emptyXaiBillingSummary = (): XaiBillingSummary => ({ + mode: 'billing', + source: 'cli-chat-proxy', + periodType: 'unknown', + usagePercent: null, + productUsage: [], + monthlyLimitCents: null, + usedCents: null, + includedUsedCents: null, + onDemandCapCents: null, + onDemandUsedCents: null, + onDemandUsedPercent: null, + usedPercent: null, +}); + +function xaiPeriodInstants( + periodStart: string | undefined, + periodEnd: string | undefined +): { resetAtMs: number | null; periodHours: number | null } { + const resetAtMs = resolveResetMs([periodEnd]); + const startMs = resolveResetMs([periodStart]); + const periodHours = + resetAtMs !== null && startMs !== null && resetAtMs > startMs + ? (resetAtMs - startMs) / 3_600_000 + : null; + return { resetAtMs, periodHours }; +} + +export function buildXaiBillingSummary( + config: XaiBillingConfig | null | undefined +): XaiBillingSummary | null { + if (!config || typeof config !== 'object') return null; + + const summary = emptyXaiBillingSummary(); + const currentPeriod = config.currentPeriod ?? config.current_period ?? null; + const periodType = resolveXaiPeriodType(currentPeriod); + const creditUsagePercent = normalizeNumberValue( + config.creditUsagePercent ?? config.credit_usage_percent + ); + const periodStart = + normalizeStringValue(currentPeriod?.start) ?? + normalizeStringValue(config.billingPeriodStart ?? config.billing_period_start) ?? + undefined; + const periodEnd = + normalizeStringValue(currentPeriod?.end) ?? + normalizeStringValue(config.billingPeriodEnd ?? config.billing_period_end) ?? + undefined; + const productUsage = normalizeXaiProductUsage( + config.productUsage ?? config.product_usage, + 'Product' + ); + + const monthlyLimitCents = normalizeXaiCentValue(config.monthlyLimit ?? config.monthly_limit); + const usedCents = normalizeXaiCentValue(config.used); + const onDemandCapCents = normalizeXaiCentValue(config.onDemandCap ?? config.on_demand_cap); + const explicitOnDemandUsedCents = normalizeXaiCentValue( + config.onDemandUsed ?? config.on_demand_used + ); + const billingPeriodStart = + normalizeStringValue(config.billingPeriodStart ?? config.billing_period_start) ?? undefined; + const billingPeriodEnd = + normalizeStringValue(config.billingPeriodEnd ?? config.billing_period_end) ?? undefined; + + const includedUsedCents = + usedCents === null + ? null + : monthlyLimitCents !== null && monthlyLimitCents > 0 + ? Math.min(usedCents, monthlyLimitCents) + : usedCents; + const derivedOnDemandUsedCents = + usedCents !== null && monthlyLimitCents !== null + ? Math.max(0, usedCents - monthlyLimitCents) + : null; + const onDemandUsedCents = explicitOnDemandUsedCents ?? derivedOnDemandUsedCents; + const usedPercent = + monthlyLimitCents !== null && monthlyLimitCents > 0 && includedUsedCents !== null + ? (includedUsedCents / monthlyLimitCents) * 100 + : null; + const onDemandUsedPercent = + onDemandCapCents !== null && onDemandCapCents > 0 && onDemandUsedCents !== null + ? (onDemandUsedCents / onDemandCapCents) * 100 + : null; + + const hasWeeklyData = + creditUsagePercent !== null || periodType === 'weekly' || productUsage.length > 0; + const hasMonthlyData = + monthlyLimitCents !== null || + usedCents !== null || + (!hasWeeklyData && (onDemandCapCents !== null || !!billingPeriodEnd)); + + if (!hasWeeklyData && !hasMonthlyData) return null; + + summary.periodType = hasWeeklyData + ? periodType === 'unknown' + ? 'weekly' + : periodType + : 'monthly'; + summary.usagePercent = hasWeeklyData ? creditUsagePercent : usedPercent; + summary.periodStart = hasWeeklyData ? periodStart : billingPeriodStart; + summary.periodEnd = hasWeeklyData ? periodEnd : billingPeriodEnd; + summary.productUsage = productUsage; + summary.monthlyLimitCents = monthlyLimitCents; + summary.usedCents = usedCents; + summary.includedUsedCents = includedUsedCents; + summary.onDemandCapCents = onDemandCapCents; + summary.onDemandUsedCents = onDemandUsedCents; + summary.onDemandUsedPercent = onDemandUsedPercent; + summary.billingPeriodStart = hasMonthlyData ? billingPeriodStart : undefined; + summary.billingPeriodEnd = hasMonthlyData ? billingPeriodEnd : undefined; + summary.usedPercent = usedPercent; + + const periodInstants = xaiPeriodInstants(summary.periodStart, summary.periodEnd); + summary.resetAtMs = periodInstants.resetAtMs; + summary.periodHours = periodInstants.periodHours; + + return summary; +} + +export function mergeXaiBillingSummaries( + primary: XaiBillingSummary | null, + fallback: XaiBillingSummary | null +): XaiBillingSummary | null { + if (!primary) return fallback; + if (!fallback) return primary; + + const periodSummary = + primary.periodType !== 'unknown' + ? primary + : fallback.periodType !== 'unknown' + ? fallback + : primary; + const periodStart = periodSummary.periodStart; + const periodEnd = periodSummary.periodEnd; + const periodInstants = xaiPeriodInstants(periodStart, periodEnd); + + return { + mode: 'billing', + source: 'cli-chat-proxy', + periodType: periodSummary.periodType, + usagePercent: primary.usagePercent ?? fallback.usagePercent, + periodStart, + periodEnd, + resetAtMs: periodInstants.resetAtMs, + periodHours: periodInstants.periodHours, + productUsage: primary.productUsage.length > 0 ? primary.productUsage : fallback.productUsage, + monthlyLimitCents: primary.monthlyLimitCents ?? fallback.monthlyLimitCents, + usedCents: primary.usedCents ?? fallback.usedCents, + includedUsedCents: primary.includedUsedCents ?? fallback.includedUsedCents, + onDemandCapCents: primary.onDemandCapCents ?? fallback.onDemandCapCents, + onDemandUsedCents: primary.onDemandUsedCents ?? fallback.onDemandUsedCents, + onDemandUsedPercent: primary.onDemandUsedPercent ?? fallback.onDemandUsedPercent, + billingPeriodStart: primary.billingPeriodStart ?? fallback.billingPeriodStart, + billingPeriodEnd: primary.billingPeriodEnd ?? fallback.billingPeriodEnd, + usedPercent: primary.usedPercent ?? fallback.usedPercent, + }; +} diff --git a/tests/quotaRendering.test.tsx b/tests/quotaRendering.test.tsx index 649837e..4608348 100644 --- a/tests/quotaRendering.test.tsx +++ b/tests/quotaRendering.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test'; import { renderToStaticMarkup } from 'react-dom/server'; import { I18nProvider } from '../src/i18n'; import { QuotaCard } from '../src/pages/QuotaPage'; +import { quotaRowsFor } from '../src/services/quotaService'; import type { QuotaState } from '../src/services/quotaService'; const render = (quota: QuotaState, provider = 'codex') => renderToStaticMarkup( @@ -28,6 +29,19 @@ describe('quota card rendering', () => { expect(html).toContain('获取/刷新额度'); }); + it('xAI 探测成功显示可用说明,100% 额度保留刷新按钮', () => { + const healthy = render({ + status: 'success', rows: quotaRowsFor('xai', { mode: 'paid-health' }), + }, 'xai'); + expect(healthy).toContain('付费 API 对话可用'); + expect(healthy).not.toContain('real-quota-track'); + const full = render({ + status: 'success', rows: quotaRowsFor('xai', { config: { creditUsagePercent: 0 } }), + }, 'xai'); + expect(full).toContain('剩余 100%'); + expect(full).toContain('width:100%'); + expect(full).not.toContain('disabled=""'); + }); it('缓存中的原始重置时间提供动态提示,过期额度不伪造为已恢复', () => { const html = render({ status: 'success', rows: [{ label: '5h', remainingPercent: 0, resetAtMs: Date.parse('2020-01-01T00:00:00Z') }] }); expect(html).toContain('重置时间已到,请刷新确认'); diff --git a/tests/quotaRequests.test.ts b/tests/quotaRequests.test.ts index d90414e..87f124c 100644 --- a/tests/quotaRequests.test.ts +++ b/tests/quotaRequests.test.ts @@ -122,38 +122,90 @@ describe('quota API compatibility', () => { }); }); -describe('xAI quota queries without paid probes', () => { +describe('xAI quota queries aligned with Management Center', () => { const file = { name: 'xai.json', provider: 'x-ai', auth_index: 'x' }; - it('单个账单接口失败仍展示另一接口的数据,不触发收费探测', async () => { + it('一个账单失败时保留另一个接口的真实额度,不探测', async () => { handler = (request) => request.url.includes('format=credits') ? { status_code: 403, body: 'weekly denied' } - : success({ config: { monthlyLimit: 1000, used: 200 } }); + : success({ config: { monthlyLimit: 1000, used: 0 } }); const result = await loadQuota({ ...file, metadata: { user: { id: 'u' } } }); - expect(result.status).toBe('success'); - expect(result.rows[0].remainingPercent).toBe(80); + expect(result).toMatchObject({ status: 'success', rows: [{ remainingPercent: 100 }] }); expect(calls).toHaveLength(2); calls.forEach((request) => expect(request.header['x-userid']).toBe('u')); }); - it('查询付费账号只说明额度不可用,不要求确认也不发送计费请求', async () => { + it('付费账号直接探测,profile 失败不影响聊天成功', async () => { + handler = (request) => request.url.endsWith('/me') + ? { status_code: 403, body: 'profile denied' } : success({}); const result = await loadQuota({ ...file, using_api: true, prefix: 'paid' }); - expect(result.status).toBe('success'); + expect(result).toMatchObject({ status: 'success', plan: 'Paid' }); expect(result.rows[0].remainingPercent).toBeNull(); - expect(result.rows[0].detail).toBe('此账号不提供剩余额度。'); - expect(post).not.toHaveBeenCalled(); + expect(result.rows[0].detail).toContain('付费 API 对话可用'); + expect(calls).toHaveLength(2); + expect(calls.map((request) => request.url)).toEqual([ + 'https://api.x.ai/v1/me', 'https://api.x.ai/v1/chat/completions', + ]); + expect(JSON.parse(calls[1].data!)).toEqual({ + model: 'grok-4.5', messages: [{ role: 'user', content: 'ping' }], + max_tokens: 1, stream: false, + }); + expect(calls[1].method).toBe('POST'); + expect(calls[1].header).toEqual({ + Authorization: 'Bearer $TOKEN$', accept: 'application/json', 'Content-Type': 'application/json', + }); + post.mock.calls.forEach((call) => expect(call[2]).toEqual({ timeoutMs: 15000 })); }); - it('两个账单均无数据时返回查询错误,不自动发起付费测试', async () => { - handler = (request) => request.url.includes('cli-chat-proxy') ? { status_code: 403, body: 'billing denied' } - : { status_code: 429, body: 'paid denied' }; + it('账单为空后探测成功只显示账户可用,不伪造额度', async () => { + handler = () => success({}); const result = await loadQuota(file); - expect(result).toMatchObject({ status: 'error', error: 'billing denied' }); + expect(result).toMatchObject({ status: 'success', plan: 'Paid', rows: [{ remainingPercent: null }] }); + expect(calls).toHaveLength(4); + }); + + it('账单和回退都失败时保留原始账单错误', async () => { + handler = (request) => request.url.includes('cli-chat-proxy') + ? { status_code: 403, body: 'billing denied' } : { status_code: 429, body: 'paid denied' }; + expect(await loadQuota(file)).toMatchObject({ status: 'error', error: 'billing denied' }); + expect(calls).toHaveLength(4); + }); + + it('已识别付费账号聊天失败时显示聊天错误', async () => { + handler = (request) => request.url.endsWith('/me') + ? success({}) : { status_code: 429, body: 'chat denied' }; + expect(await loadQuota({ ...file, using_api: true, prefix: 'paid' })) + .toMatchObject({ status: 'error', error: 'chat denied' }); + expect(calls).toHaveLength(2); + }); + + it('合并缺失百分比与零用量后仍查询成功,不触发探测', async () => { + handler = (request) => success({ config: request.url.includes('format=credits') + ? { currentPeriod: { type: 'weekly' } } : { creditUsagePercent: 0 } }); + expect(await loadQuota(file)).toMatchObject({ + status: 'success', rows: [{ remainingPercent: 100 }], + }); + expect(calls).toHaveLength(2); + }); + + it('已识别的空周周期或零按量上限不触发付费探测', async () => { + for (const config of [{ currentPeriod: { type: 'weekly' } }, { onDemandCap: 0 }]) { + calls = []; + handler = () => success({ config }); + expect(await loadQuota(file)).toMatchObject({ + status: 'success', rows: [{ remainingPercent: null }], + }); + expect(calls).toHaveLength(2); + } + }); + it('仅有账单周期也不触发付费回退', async () => { + handler = () => success({ config: { billingPeriodEnd: '2030-01-01T00:00:00Z' } }); + expect(await loadQuota(file)).toMatchObject({ + status: 'success', rows: [{ remainingPercent: null }], + }); expect(calls).toHaveLength(2); - expect(calls.every((request) => request.method === 'GET')).toBe(true); }); }); - describe('quota request synchronization', () => { it('跨页面重复刷新共用同一个请求', async () => { let release!: () => void; diff --git a/tests/quotaService.test.ts b/tests/quotaService.test.ts index 19a65f4..c83e040 100644 --- a/tests/quotaService.test.ts +++ b/tests/quotaService.test.ts @@ -302,4 +302,24 @@ describe('quotaRowsFor', () => { expect(rows.find((row) => row.label === '月度包含额度')?.remainingPercent).toBe(0); expect(rows.find((row) => row.label === '按量付费额度')?.remainingPercent).toBe(60); }); + + it('xAI 保留 0 和 100 的边界值,缺失用量不显示全额余额', () => { + expect(quotaRowsFor('xai', { config: { creditUsagePercent: 0 } })[0].remainingPercent).toBe(100); + expect(quotaRowsFor('xai', { config: { creditUsagePercent: 100 } })[0].remainingPercent).toBe(0); + expect(quotaRowsFor('xai', { config: { monthlyLimit: { val: 15000 }, used: { val: 0 } } })[0].remainingPercent).toBe(100); + const unknown = quotaRowsFor('xai', { config: { monthlyLimit: 15000 } })[0]; + expect(unknown.remainingPercent).toBeNull(); + expect(unknown.detail).not.toBe('US$150.00 / US$150.00'); + expect(quotaRowsFor('xai', { config: { monthlyLimit: 15000, used: 15000 } })[0].remainingPercent).toBe(0); + }); + + it('xAI 按字段合并并保留活动周期,不由月账单补齐周重置时间', () => { + const rows = quotaRowsFor('xai', { + weekly: { config: { currentPeriod: { type: 'weekly' } } }, + 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].resetAtMs).toBeUndefined(); + }); });