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:
1 parent
40ba85d6f0
commit
7df4a28428
8 files changed
+494
-108
No files matched your search
@@ -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': '現在適用可能な回数',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '当前适用次数',
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user