fix(quota): align Grok quota queries with Management Center

- Merge weekly and monthly billing fields to preserve 100% remaining quota
- Display unknown usage instead of assuming a full balance
- Align paid account health checks and billing failure fallback
- Add localized messages and regression tests
This commit is contained in:
lzt404 committed 2026-09-08 13:35:08 +08:00
1 parent 40ba85d6f0
commit 7df4a28428
8 files changed
+494 -108

No files matched your search

+1
View File
@@ -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': '現在適用可能な回数',
+1
View File
@@ -179,6 +179,7 @@ export const en: Record<MessageKey, string> = {
'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',
+1
View File
@@ -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': '当前适用次数',
+86 -93
View File
@@ -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<string>();
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<unknown> => {
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<unknown> => {
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<unknown> => {
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 => {
+304
View File
@@ -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,
};
}
+14
View File
@@ -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('重置时间已到,请刷新确认');
+67 -15
View File
@@ -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;
+20
View File
@@ -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();
});
});