mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-10-16 14:57:05 +08:00
Merge remote-tracking branch 'origin/main' into dev
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { getStatusFromError } from '@/utils/quota';
|
||||
import { getQuotaCacheKey } from '@/utils/quota/identity';
|
||||
import { enrichQuotaInBackground } from '../quotaEnrichment';
|
||||
import { getQuotaMap, getQuotaSetter, type QuotaAdapter, type QuotaCardState } from '../providers';
|
||||
|
||||
const getQuotaState = (adapter: QuotaAdapter, file: AuthFileItem): QuotaCardState | undefined =>
|
||||
@@ -42,10 +43,12 @@ export function useQuotaActions(disableControls: boolean) {
|
||||
try {
|
||||
const data = await adapter.fetchQuota(file, t);
|
||||
commitIfQuotaCacheCurrent(cacheGeneration, () => {
|
||||
const successState = adapter.buildSuccessState(data);
|
||||
setQuota((prev) => ({
|
||||
...prev,
|
||||
[cacheKey]: adapter.buildSuccessState(data),
|
||||
[cacheKey]: successState,
|
||||
}));
|
||||
void enrichQuotaInBackground(adapter, file, data, successState, t);
|
||||
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -14,7 +14,8 @@ import { captureQuotaCacheGeneration, commitIfQuotaCacheCurrent } from '@/stores
|
||||
import { getStatusFromError } from '@/utils/quota';
|
||||
import { getQuotaCacheKey } from '@/utils/quota/identity';
|
||||
import type { QuotaFileEntry } from '../logic';
|
||||
import { QUOTA_ADAPTERS, getQuotaSetter } from '../providers';
|
||||
import { QUOTA_ADAPTERS, getQuotaSetter, type QuotaCardState } from '../providers';
|
||||
import { enrichQuotaInBackground } from '../quotaEnrichment';
|
||||
import type { QuotaProviderType } from '../providers/types';
|
||||
|
||||
interface BatchFetchResult {
|
||||
@@ -85,6 +86,7 @@ export function useQuotaBatchLoader() {
|
||||
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
|
||||
const committedStates = new Map<string, QuotaCardState>();
|
||||
setQuota((prev) => {
|
||||
const nextState = { ...prev };
|
||||
results.forEach((result) => {
|
||||
@@ -98,12 +100,19 @@ export function useQuotaBatchLoader() {
|
||||
result.error || t('common.unknown_error'),
|
||||
result.errorStatus
|
||||
);
|
||||
committedStates.set(result.cacheKey, nextState[result.cacheKey]);
|
||||
},
|
||||
result.name
|
||||
);
|
||||
});
|
||||
return nextState;
|
||||
});
|
||||
results.forEach((result, index) => {
|
||||
const state = committedStates.get(result.cacheKey);
|
||||
if (result.status === 'success' && state) {
|
||||
void enrichQuotaInBackground(adapter, entries[index].file, result.data, state, t);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface QuotaAdapter {
|
||||
i18nPrefix: string;
|
||||
filterFn: (file: AuthFileItem) => boolean;
|
||||
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<unknown>;
|
||||
enrichQuota?: (file: AuthFileItem, data: unknown, t: TFunction) => Promise<unknown>;
|
||||
resetQuota?: (file: AuthFileItem, t: TFunction) => Promise<unknown>;
|
||||
canResetQuota?: (quota: QuotaCardState) => boolean;
|
||||
storeSelector: (state: QuotaStore) => Record<string, QuotaCardState>;
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface QuotaProviderData<TState, TData> {
|
||||
i18nPrefix: string;
|
||||
filterFn: (file: AuthFileItem) => boolean;
|
||||
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<TData>;
|
||||
/** Optional details loaded only after the primary quota has been committed. */
|
||||
enrichQuota?: (file: AuthFileItem, data: TData, t: TFunction) => Promise<TData>;
|
||||
resetQuota?: (file: AuthFileItem, t: TFunction) => Promise<TData>;
|
||||
canResetQuota?: (quota: TState) => boolean;
|
||||
storeSelector: (state: QuotaStore) => Record<string, TState>;
|
||||
|
||||
@@ -51,6 +51,15 @@ const formatXaiPercent = (value: number | null): string => {
|
||||
const XAI_SUPERGROK_LIMIT_CENTS = 15_000;
|
||||
const XAI_SUPERGROK_HEAVY_LIMIT_CENTS = 150_000;
|
||||
|
||||
const planValueClass = (
|
||||
tier: XaiBillingSummary['planTier'],
|
||||
classes: QuotaBodyProps<XaiQuotaState>['classes']
|
||||
) => {
|
||||
if (tier === 'elite') return classes.elitePlanValue;
|
||||
if (tier === 'premium') return classes.premiumPlanValue;
|
||||
return classes.codexPlanValue;
|
||||
};
|
||||
|
||||
const resolveXaiPlan = (
|
||||
monthlyLimitCents: number | null
|
||||
): { labelKey: string; premium: boolean } | null => {
|
||||
@@ -85,7 +94,15 @@ export function XaiQuotaBody({ quota, classes }: QuotaBodyProps<XaiQuotaState>)
|
||||
<>
|
||||
<div className={classes.codexPlan}>
|
||||
<span className={classes.codexPlanLabel}>{t('xai_quota.plan_label')}</span>
|
||||
<span className={classes.premiumPlanValue}>{t('xai_quota.plan_paid')}</span>
|
||||
<span
|
||||
className={
|
||||
billing.planLabel
|
||||
? planValueClass(billing.planTier, classes)
|
||||
: classes.premiumPlanValue
|
||||
}
|
||||
>
|
||||
{billing.planLabel ?? t('xai_quota.plan_paid')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={classes.quotaMessage}>{t('xai_quota.paid_health')}</div>
|
||||
</>
|
||||
@@ -138,13 +155,45 @@ export function XaiQuotaBody({ quota, classes }: QuotaBodyProps<XaiQuotaState>)
|
||||
Boolean(billing.billingPeriodEnd)) &&
|
||||
!(hasWeeklyData && billing.monthlyLimitCents === 0 && billing.usedCents === 0);
|
||||
|
||||
const subscriptionLabel = billing.planLabel ?? (plan ? t(`xai_quota.${plan.labelKey}`) : null);
|
||||
const subscriptionClass = billing.planLabel
|
||||
? planValueClass(billing.planTier, classes)
|
||||
: plan?.premium
|
||||
? classes.premiumPlanValue
|
||||
: classes.codexPlanValue;
|
||||
const headerReset = buildResetDisplay(
|
||||
null,
|
||||
hasWeeklyData ? billing.resetAtMs : parseIsoToMs(billing.billingPeriodEnd),
|
||||
now,
|
||||
locale
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{plan && (
|
||||
{(subscriptionLabel || headerReset) && (
|
||||
<div className={classes.codexPlan}>
|
||||
<span className={classes.codexPlanLabel}>{t('xai_quota.plan_label')}</span>
|
||||
<span className={plan.premium ? classes.premiumPlanValue : classes.codexPlanValue}>
|
||||
{t(`xai_quota.${plan.labelKey}`)}
|
||||
{subscriptionLabel && (
|
||||
<span className={classes.codexPlanItem}>
|
||||
<span className={classes.codexPlanLabel}>{t('xai_quota.plan_label')}</span>
|
||||
<span className={subscriptionClass}>{subscriptionLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
{headerReset && (
|
||||
<span className={classes.codexPlanItem}>
|
||||
<span className={classes.codexPlanLabel}>{t('xai_quota.resets_label')}</span>
|
||||
<span className={classes.codexPlanValue}>{headerReset.absolute}</span>
|
||||
{headerReset.relative && (
|
||||
<span className={classes.quotaResetRelative}>{headerReset.relative}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{typeof billing.prepaidBalanceCents === 'number' && billing.prepaidBalanceCents > 0 && (
|
||||
<div className={classes.codexPlan}>
|
||||
<span className={classes.codexPlanLabel}>{t('xai_quota.prepaid')}</span>
|
||||
<span className={classes.quotaAmount}>
|
||||
{formatUsdFromCents(billing.prepaidBalanceCents)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
XAI_API_REQUEST_HEADERS,
|
||||
XAI_BILLING_MONTHLY_URL,
|
||||
XAI_BILLING_WEEKLY_URL,
|
||||
XAI_SETTINGS_URL,
|
||||
XAI_USER_URL,
|
||||
XAI_PAID_HEALTH_MODEL,
|
||||
XAI_REQUEST_HEADERS,
|
||||
normalizeStringValue,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
buildXaiBillingSummary,
|
||||
buildXaiPaidHealthSummary,
|
||||
mergeXaiBillingSummaries,
|
||||
resolveXaiSubscriptionPlan,
|
||||
createStatusError,
|
||||
isDisabledAuthFile,
|
||||
isPaidXaiAuthFile,
|
||||
@@ -28,6 +31,7 @@ import { normalizeAuthIndex } from '@/utils/authIndex';
|
||||
import type { QuotaProviderData } from '../types';
|
||||
|
||||
const XAI_PAID_HEALTH_REQUEST_TIMEOUT_MS = 15000;
|
||||
const XAI_SUBSCRIPTION_REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
const toXaiRecord = (value: unknown): Record<string, unknown> | null => {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
@@ -145,6 +149,58 @@ const requestXaiPaidHealth = async (authIndex: string): Promise<XaiBillingSummar
|
||||
return buildXaiPaidHealthSummary(profile);
|
||||
};
|
||||
|
||||
const readJsonRecord = (result: { statusCode: number; body?: unknown; bodyText?: string }) => {
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) return null;
|
||||
const body = result.body ?? result.bodyText;
|
||||
if (typeof body === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as unknown;
|
||||
return toXaiRecord(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return toXaiRecord(body);
|
||||
};
|
||||
|
||||
const readPlanField = (record: Record<string, unknown> | null, keys: string[]) => {
|
||||
if (!record) return null;
|
||||
for (const key of keys) {
|
||||
const value = normalizeStringValue(record[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const requestXaiSubscription = async (authIndex: string) => {
|
||||
const header = { ...XAI_REQUEST_HEADERS };
|
||||
const [userResult, settingsResult] = await Promise.allSettled([
|
||||
apiCallApi.request(
|
||||
{ authIndex, method: 'GET', url: XAI_USER_URL, header },
|
||||
{ timeout: XAI_SUBSCRIPTION_REQUEST_TIMEOUT_MS }
|
||||
),
|
||||
apiCallApi.request(
|
||||
{ authIndex, method: 'GET', url: XAI_SETTINGS_URL, header },
|
||||
{ timeout: XAI_SUBSCRIPTION_REQUEST_TIMEOUT_MS }
|
||||
),
|
||||
]);
|
||||
const user = userResult.status === 'fulfilled' ? readJsonRecord(userResult.value) : null;
|
||||
const settings =
|
||||
settingsResult.status === 'fulfilled' ? readJsonRecord(settingsResult.value) : null;
|
||||
return resolveXaiSubscriptionPlan(
|
||||
readPlanField(user, ['subscriptionTier', 'subscription_tier']),
|
||||
readPlanField(settings, ['subscription_tier_display', 'subscriptionTierDisplay'])
|
||||
);
|
||||
};
|
||||
|
||||
const withSubscriptionPlan = (
|
||||
summary: XaiBillingSummary,
|
||||
plan: { label: string; tier: 'elite' | 'premium' | 'standard' } | null
|
||||
): XaiBillingSummary => {
|
||||
if (!plan) return summary;
|
||||
return { ...summary, planLabel: plan.label, planTier: plan.tier };
|
||||
};
|
||||
|
||||
const fetchXaiQuota = async (file: AuthFileItem, t: TFunction): Promise<XaiBillingSummary> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndex(rawAuthIndex);
|
||||
@@ -184,6 +240,11 @@ export const XAI_CONFIG: QuotaProviderData<XaiQuotaState, XaiBillingSummary> = {
|
||||
i18nPrefix: 'xai_quota',
|
||||
filterFn: (file) => isXaiFile(file) && !isDisabledAuthFile(file),
|
||||
fetchQuota: fetchXaiQuota,
|
||||
enrichQuota: async (file, summary) => {
|
||||
const authIndex = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
|
||||
if (!authIndex) return summary;
|
||||
return withSubscriptionPlan(summary, await requestXaiSubscription(authIndex));
|
||||
},
|
||||
storeSelector: (state) => state.xaiQuota,
|
||||
storeSetter: 'setXaiQuota',
|
||||
buildLoadingState: () => ({ status: 'loading', billing: null }),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import {
|
||||
captureQuotaCacheGeneration,
|
||||
commitIfQuotaCacheCurrent,
|
||||
useQuotaStore,
|
||||
} from '@/stores/useQuotaStore';
|
||||
import { getQuotaCacheKey } from '@/utils/quota/identity';
|
||||
import type { QuotaAdapter, QuotaCardState, QuotaMapUpdater } from './providers';
|
||||
|
||||
/** Optional metadata must neither delay quota rendering nor replace a newer result. */
|
||||
export async function enrichQuotaInBackground(
|
||||
adapter: QuotaAdapter,
|
||||
file: AuthFileItem,
|
||||
data: unknown,
|
||||
expectedState: QuotaCardState,
|
||||
t: TFunction
|
||||
): Promise<void> {
|
||||
if (!adapter.enrichQuota) return;
|
||||
const cacheKey = getQuotaCacheKey(file);
|
||||
const currentState = () => adapter.storeSelector(useQuotaStore.getState())[cacheKey];
|
||||
if (currentState() !== expectedState) return;
|
||||
const generation = captureQuotaCacheGeneration(file.name);
|
||||
|
||||
try {
|
||||
const enriched = await adapter.enrichQuota(file, data, t);
|
||||
if (enriched === data) return;
|
||||
commitIfQuotaCacheCurrent(generation, () => {
|
||||
const setQuota = useQuotaStore.getState()[adapter.storeSetter] as QuotaMapUpdater;
|
||||
setQuota((prev) => {
|
||||
if (prev[cacheKey] !== expectedState) return prev;
|
||||
return { ...prev, [cacheKey]: adapter.buildSuccessState(enriched) };
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// Keep the successful quota and its original plan fallback on optional failures.
|
||||
}
|
||||
}
|
||||
@@ -624,6 +624,8 @@
|
||||
"reset_at": "Resets {{time}}",
|
||||
"product_usage": "{{product}} usage",
|
||||
"plan_label": "Plan",
|
||||
"resets_label": "Resets",
|
||||
"prepaid": "Prepaid",
|
||||
"plan_supergrok": "SuperGrok",
|
||||
"plan_supergrok_heavy": "SuperGrok Heavy",
|
||||
"plan_paid": "Paid",
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
"reset_at": "Сброс {{time}}",
|
||||
"product_usage": "Использование {{product}}",
|
||||
"plan_label": "План",
|
||||
"resets_label": "Сброс",
|
||||
"prepaid": "Предоплата",
|
||||
"plan_supergrok": "SuperGrok",
|
||||
"plan_supergrok_heavy": "SuperGrok Heavy",
|
||||
"plan_paid": "Платный",
|
||||
|
||||
@@ -622,6 +622,8 @@
|
||||
"reset_at": "重置 {{time}}",
|
||||
"product_usage": "{{product}} 使用",
|
||||
"plan_label": "套餐",
|
||||
"resets_label": "重置",
|
||||
"prepaid": "预付",
|
||||
"plan_supergrok": "SuperGrok",
|
||||
"plan_supergrok_heavy": "SuperGrok Heavy",
|
||||
"plan_paid": "付费",
|
||||
|
||||
@@ -622,6 +622,8 @@
|
||||
"reset_at": "重置 {{time}}",
|
||||
"product_usage": "{{product}} 使用",
|
||||
"plan_label": "方案",
|
||||
"resets_label": "重置",
|
||||
"prepaid": "預付",
|
||||
"plan_supergrok": "SuperGrok",
|
||||
"plan_supergrok_heavy": "SuperGrok Heavy",
|
||||
"plan_paid": "付費",
|
||||
|
||||
@@ -405,6 +405,8 @@ export interface XaiBillingConfig {
|
||||
on_demand_cap?: XaiBillingCent | number | string | null;
|
||||
onDemandUsed?: XaiBillingCent | number | string | null;
|
||||
on_demand_used?: XaiBillingCent | number | string | null;
|
||||
prepaidBalance?: XaiBillingCent | number | string | null;
|
||||
prepaid_balance?: XaiBillingCent | number | string | null;
|
||||
billingPeriodStart?: string;
|
||||
billing_period_start?: string;
|
||||
billingPeriodEnd?: string;
|
||||
@@ -440,6 +442,10 @@ export interface XaiBillingSummary {
|
||||
onDemandCapCents: number | null;
|
||||
onDemandUsedCents: number | null;
|
||||
onDemandUsedPercent: number | null;
|
||||
prepaidBalanceCents?: number | null;
|
||||
/** Display name from Grok settings, for example "SuperGrok Heavy". */
|
||||
planLabel?: string;
|
||||
planTier?: 'elite' | 'premium' | 'standard';
|
||||
billingPeriodStart?: string;
|
||||
billingPeriodEnd?: string;
|
||||
usedPercent: number | null;
|
||||
|
||||
@@ -426,6 +426,7 @@ const emptyXaiBillingSummary = (): XaiBillingSummary => ({
|
||||
onDemandCapCents: null,
|
||||
onDemandUsedCents: null,
|
||||
onDemandUsedPercent: null,
|
||||
prepaidBalanceCents: null,
|
||||
usedPercent: null,
|
||||
});
|
||||
|
||||
@@ -450,6 +451,22 @@ function xaiPeriodInstants(
|
||||
return { resetAtMs, periodHours };
|
||||
}
|
||||
|
||||
export function resolveXaiSubscriptionPlan(
|
||||
tier: string | null | undefined,
|
||||
display: string | null | undefined
|
||||
): { label: string; tier: 'elite' | 'premium' | 'standard' } | null {
|
||||
const label = normalizeStringValue(display) ?? normalizeStringValue(tier);
|
||||
if (!label) return null;
|
||||
const key = `${display ?? ''} ${tier ?? ''}`.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
const planTier =
|
||||
key.includes('heavy')
|
||||
? 'elite'
|
||||
: key.includes('supergrok') || key.includes('premium')
|
||||
? 'premium'
|
||||
: 'standard';
|
||||
return { label, tier: planTier };
|
||||
}
|
||||
|
||||
export function buildXaiBillingSummary(
|
||||
config: XaiBillingConfig | null | undefined
|
||||
): XaiBillingSummary | null {
|
||||
@@ -496,6 +513,9 @@ export function buildXaiBillingSummary(
|
||||
? Math.max(0, usedCents - monthlyLimitCents)
|
||||
: null;
|
||||
const onDemandUsedCents = explicitOnDemandUsedCents ?? derivedOnDemandUsedCents;
|
||||
const prepaidBalanceCents = normalizeXaiCentValue(
|
||||
config.prepaidBalance ?? config.prepaid_balance
|
||||
);
|
||||
const usedPercent =
|
||||
monthlyLimitCents !== null && monthlyLimitCents > 0 && includedUsedCents !== null
|
||||
? (includedUsedCents / monthlyLimitCents) * 100
|
||||
@@ -529,6 +549,7 @@ export function buildXaiBillingSummary(
|
||||
summary.onDemandCapCents = onDemandCapCents;
|
||||
summary.onDemandUsedCents = onDemandUsedCents;
|
||||
summary.onDemandUsedPercent = onDemandUsedPercent;
|
||||
summary.prepaidBalanceCents = prepaidBalanceCents;
|
||||
summary.billingPeriodStart = hasMonthlyData ? billingPeriodStart : undefined;
|
||||
summary.billingPeriodEnd = hasMonthlyData ? billingPeriodEnd : undefined;
|
||||
summary.usedPercent = usedPercent;
|
||||
@@ -578,6 +599,9 @@ export function mergeXaiBillingSummaries(
|
||||
onDemandCapCents: primary.onDemandCapCents ?? fallback.onDemandCapCents,
|
||||
onDemandUsedCents: primary.onDemandUsedCents ?? fallback.onDemandUsedCents,
|
||||
onDemandUsedPercent: primary.onDemandUsedPercent ?? fallback.onDemandUsedPercent,
|
||||
prepaidBalanceCents: primary.prepaidBalanceCents ?? fallback.prepaidBalanceCents,
|
||||
planLabel: primary.planLabel ?? fallback.planLabel,
|
||||
planTier: primary.planTier ?? fallback.planTier,
|
||||
billingPeriodStart: primary.billingPeriodStart ?? fallback.billingPeriodStart,
|
||||
billingPeriodEnd: primary.billingPeriodEnd ?? fallback.billingPeriodEnd,
|
||||
usedPercent: primary.usedPercent ?? fallback.usedPercent,
|
||||
|
||||
@@ -154,6 +154,8 @@ export const KIMI_REQUEST_HEADERS = {
|
||||
// xAI/Grok API configuration
|
||||
export const XAI_BILLING_WEEKLY_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits';
|
||||
export const XAI_BILLING_MONTHLY_URL = 'https://cli-chat-proxy.grok.com/v1/billing';
|
||||
export const XAI_USER_URL = 'https://cli-chat-proxy.grok.com/v1/user?include=subscription';
|
||||
export const XAI_SETTINGS_URL = 'https://cli-chat-proxy.grok.com/v1/settings';
|
||||
export const XAI_API_ME_URL = 'https://api.x.ai/v1/me';
|
||||
export const XAI_API_CHAT_URL = 'https://api.x.ai/v1/chat/completions';
|
||||
export const XAI_PAID_HEALTH_MODEL = 'grok-4.5';
|
||||
|
||||
@@ -78,7 +78,8 @@ describe('xAI paid OAuth quota fallback', () => {
|
||||
);
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual([XAI_API_ME_URL, XAI_API_CHAT_URL]);
|
||||
expect(JSON.parse(requests[1]?.data ?? '{}')).toMatchObject({
|
||||
const chatRequest = requests.find((request) => request.url === XAI_API_CHAT_URL);
|
||||
expect(JSON.parse(chatRequest?.data ?? '{}')).toMatchObject({
|
||||
model: 'grok-4.5',
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import i18n from '@/i18n';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { apiCallApi, type ApiCallResult } from '@/services/api';
|
||||
import { useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { XAI_CONFIG } from '@/features/quota/providers/xai/data';
|
||||
import type { QuotaAdapter } from '@/features/quota/providers';
|
||||
import { enrichQuotaInBackground } from '@/features/quota/quotaEnrichment';
|
||||
import { getQuotaCacheKey } from '@/utils/quota/identity';
|
||||
import { XAI_USER_URL, XAI_SETTINGS_URL, buildXaiBillingSummary } from '@/utils/quota';
|
||||
|
||||
const originalRequest = apiCallApi.request;
|
||||
const file: AuthFileItem = { name: 'fixture.json', type: 'xai', auth_index: 'fixture' };
|
||||
const key = getQuotaCacheKey(file);
|
||||
const adapter = XAI_CONFIG as unknown as QuotaAdapter;
|
||||
const result = (body: unknown, statusCode = 200): ApiCallResult => ({
|
||||
statusCode,
|
||||
body,
|
||||
bodyText: JSON.stringify(body),
|
||||
header: {},
|
||||
});
|
||||
const billing = () =>
|
||||
buildXaiBillingSummary({ monthlyLimit: { val: 15000 }, used: { val: 1500 } })!;
|
||||
const current = () => useQuotaStore.getState().xaiQuota[key];
|
||||
const deferred = () => {
|
||||
let resolve!: (value: ApiCallResult) => void;
|
||||
const promise = new Promise<ApiCallResult>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
const beginEnrichment = () => {
|
||||
const data = billing();
|
||||
const state = XAI_CONFIG.buildSuccessState(data);
|
||||
useQuotaStore.getState().setXaiQuota({ [key]: state });
|
||||
const pending = enrichQuotaInBackground(adapter, file, data, state, i18n.t);
|
||||
return { data, state, pending };
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
apiCallApi.request = originalRequest;
|
||||
useQuotaStore.getState().clearQuotaCache();
|
||||
});
|
||||
|
||||
test('commits quota before requesting optional plan and updates it later', async () => {
|
||||
const gate = deferred();
|
||||
const urls: string[] = [];
|
||||
apiCallApi.request = async (request, config) => {
|
||||
urls.push(request.url);
|
||||
if (request.url === XAI_USER_URL || request.url === XAI_SETTINGS_URL) {
|
||||
expect(current()?.status).toBe('success');
|
||||
expect(config?.timeout).toBe(8000);
|
||||
return gate.promise;
|
||||
}
|
||||
return result({ config: { monthlyLimit: { val: 15000 }, used: { val: 1500 } } });
|
||||
};
|
||||
const data = await XAI_CONFIG.fetchQuota(file, i18n.t);
|
||||
expect(urls).toHaveLength(2);
|
||||
expect(urls).not.toContain(XAI_USER_URL);
|
||||
const state = XAI_CONFIG.buildSuccessState(data);
|
||||
useQuotaStore.getState().setXaiQuota({ [key]: state });
|
||||
const pending = enrichQuotaInBackground(adapter, file, data, state, i18n.t);
|
||||
expect(current()).toBe(state);
|
||||
expect(current().billing?.planLabel).toBeUndefined();
|
||||
gate.resolve(result({ subscription_tier_display: 'SuperGrok Heavy' }));
|
||||
await pending;
|
||||
expect(current().billing).toMatchObject({
|
||||
...data,
|
||||
planLabel: 'SuperGrok Heavy',
|
||||
planTier: 'elite',
|
||||
});
|
||||
});
|
||||
|
||||
for (const failure of ['network', 'http', 'malformed'] as const) {
|
||||
test(`optional ${failure} failure preserves the exact original quota and fallback`, async () => {
|
||||
apiCallApi.request = async () => {
|
||||
if (failure === 'network') throw new Error('unavailable');
|
||||
return failure === 'http' ? result({}, 503) : result('not-json');
|
||||
};
|
||||
const { state, pending } = beginEnrichment();
|
||||
await pending;
|
||||
expect(current()).toBe(state);
|
||||
});
|
||||
}
|
||||
|
||||
test('uses user tier when settings fails', async () => {
|
||||
apiCallApi.request = async ({ url }) => {
|
||||
if (url === XAI_SETTINGS_URL) throw new Error('unavailable');
|
||||
return result({ subscriptionTier: 'SuperGrok' });
|
||||
};
|
||||
const { pending } = beginEnrichment();
|
||||
await pending;
|
||||
expect(current().billing).toMatchObject({ planLabel: 'SuperGrok', planTier: 'premium' });
|
||||
});
|
||||
|
||||
for (const change of ['session', 'file', 'new-result', 'refresh'] as const) {
|
||||
test(`late subscription cannot overwrite ${change}`, async () => {
|
||||
const gate = deferred();
|
||||
apiCallApi.request = async () => gate.promise;
|
||||
const { pending } = beginEnrichment();
|
||||
if (change === 'session') useQuotaStore.getState().clearQuotaCache();
|
||||
if (change === 'file') useQuotaStore.getState().clearQuotaCache([file.name]);
|
||||
if (change === 'new-result') {
|
||||
useQuotaStore.getState().setXaiQuota({ [key]: XAI_CONFIG.buildSuccessState(billing()) });
|
||||
}
|
||||
if (change === 'refresh') {
|
||||
useQuotaStore.getState().setXaiQuota({ [key]: XAI_CONFIG.buildLoadingState() });
|
||||
}
|
||||
const expected = current();
|
||||
gate.resolve(result({ subscription_tier_display: 'SuperGrok Heavy' }));
|
||||
await pending;
|
||||
expect(current()).toBe(expected);
|
||||
});
|
||||
}
|
||||
|
||||
test('unrelated file invalidation does not discard a valid subscription', async () => {
|
||||
const gate = deferred();
|
||||
apiCallApi.request = async () => gate.promise;
|
||||
const { pending } = beginEnrichment();
|
||||
useQuotaStore.getState().clearQuotaCache(['other.json']);
|
||||
gate.resolve(result({ subscription_tier_display: 'SuperGrok Heavy' }));
|
||||
await pending;
|
||||
expect(current().billing?.planLabel).toBe('SuperGrok Heavy');
|
||||
});
|
||||
@@ -15,8 +15,10 @@ import { XaiQuotaBody } from '@/features/quota/providers/xai/XaiQuotaBody';
|
||||
import { QUOTA_CLASS_KEYS, bindQuotaClasses } from '@/features/quota/types';
|
||||
import {
|
||||
buildXaiBillingSummary,
|
||||
formatInstantShort,
|
||||
formatQuotaResetTime,
|
||||
mergeXaiBillingSummaries,
|
||||
resolveXaiSubscriptionPlan,
|
||||
} from '@/utils/quota';
|
||||
import type { XaiBillingConfig, XaiQuotaState } from '@/types';
|
||||
|
||||
@@ -67,6 +69,26 @@ beforeAll(async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
});
|
||||
|
||||
describe('resolveXaiSubscriptionPlan', () => {
|
||||
test('uses the settings display name and treats Heavy as the elite badge', () => {
|
||||
expect(resolveXaiSubscriptionPlan('SuperGrokPro', 'SuperGrok Heavy')).toEqual({
|
||||
label: 'SuperGrok Heavy',
|
||||
tier: 'elite',
|
||||
});
|
||||
});
|
||||
|
||||
test('treats X Premium+ as the premium badge', () => {
|
||||
expect(resolveXaiSubscriptionPlan('XPremiumPlus', 'X Premium+')).toEqual({
|
||||
label: 'X Premium+',
|
||||
tier: 'premium',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null when neither call produced a name', () => {
|
||||
expect(resolveXaiSubscriptionPlan(null, ' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('XaiQuotaBody unavailable weekly usage', () => {
|
||||
test('reports unavailable instead of a fabricated zero when usagePercent is missing', () => {
|
||||
const quota = quotaFor(weeklyConfig(), monthlyConfig());
|
||||
@@ -151,6 +173,50 @@ describe('XaiQuotaBody unavailable weekly usage', () => {
|
||||
expect(markup).toContain('Monthly credits');
|
||||
});
|
||||
|
||||
test('shows the Grok subscription name and the weekly reset beside it', () => {
|
||||
const quota = quotaFor(
|
||||
weeklyConfig({
|
||||
creditUsagePercent: 94,
|
||||
prepaidBalance: { val: 250 },
|
||||
productUsage: [
|
||||
{ product: 'GrokBuild', usagePercent: 93 },
|
||||
{ product: 'GrokChat', usagePercent: 1 },
|
||||
],
|
||||
}),
|
||||
monthlyConfig({ used: { val: 4 } })
|
||||
);
|
||||
if (!quota.billing) throw new Error('missing billing');
|
||||
quota.billing.planLabel = 'SuperGrok Heavy';
|
||||
quota.billing.planTier = 'elite';
|
||||
|
||||
const markup = render(quota);
|
||||
|
||||
expect(markup).toContain('elitePlanValue');
|
||||
expect(markup).toContain('SuperGrok Heavy');
|
||||
expect(markup).toContain('Resets');
|
||||
expect(markup).toContain(formatInstantShort(Date.parse(WEEKLY_PERIOD_END)));
|
||||
expect(markup).toContain('GrokChat usage');
|
||||
expect(markup).toContain('Prepaid');
|
||||
expect(markup).toContain('$2.50');
|
||||
expect(markup).toContain('$0.00 / $0.00');
|
||||
});
|
||||
|
||||
test('keeps monthly amount, percentage, and meter in the remaining direction', () => {
|
||||
const markup = render(
|
||||
quotaFor(
|
||||
null,
|
||||
monthlyConfig({
|
||||
monthlyLimit: { val: 15000 },
|
||||
used: { val: 1500 },
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(markup).toContain('>90%<');
|
||||
expect(markup).toContain('$135.00 / $150.00');
|
||||
expect(markup).not.toContain('$15.00 / $150.00');
|
||||
expect(markup).toContain('width:90%');
|
||||
});
|
||||
|
||||
test('keeps the monthly-only zero row when no weekly data exists', () => {
|
||||
const markup = render(quotaFor(null, monthlyConfig()));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user