From 69ebed4f3f31575eedae9c2713bd508dc4c2344d Mon Sep 17 00:00:00 2001 From: giovannirco <34581682+giovannirco@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:32:15 -0300 Subject: [PATCH] Show the Grok subscription on the xAI quota card (#451) * feat(quota): show the Grok subscription on the xAI card The billing payload has no plan name. The display name comes from Grok settings and the tier from the user subscription include. * Refactor provider credential weight normalization tests and update API key structure - Updated test cases in `providerWeightTransformers.test.ts` to reflect changes in API key structure from 'api-key-entries' to 'keys'. - Introduced new tests in `providerWorkbenchUi.test.ts` to verify the editing surface of provider workbench. - Modified `sponsorCustomEndpoint.test.ts` to adapt to the new API key structure. - Adjusted `visualConfigAntigravitySensitiveWords.test.ts` and `visualConfigConcurrency.test.ts` to accommodate new YAML structure for sensitive words and concurrency settings. - Enhanced `visualConfigDefaults.test.ts` to ensure proper handling of boolean defaults and YAML structure. - Updated `visualConfigDevinSensitiveWords.test.ts` to reflect changes in the YAML structure for sensitive words. - Refined `visualConfigDisableImageGeneration.test.ts` to align with new multimedia settings. - Added comprehensive tests in `visualConfigV8.test.ts` to validate the v8 visual config contract and ensure scalar read/write path parity. - Improved validation tests in `visualConfigValidation.test.ts` to handle new routing and requests structure. - Cleaned up `xaiApiKeyProvider.test.ts` by removing unnecessary API client mocks and focusing on normalization of the xAI API key contract. * fix(config): prevent v8 configuration mutation regressions * feat(proxy): add proxyUrl support across various components and API calls * feat: implement model discovery merging and enhance provider forms - Add mergeDiscoveredModels function to deduplicate and merge model entries based on incoming data. - Refactor BaseProviderForm and SponsorProviderForm to utilize mergeDiscoveredModels for applying discovered models. - Update ModelEntryInput type to include sourceIndex for better tracking of model identities. - Enhance tests for provider model discovery to ensure correct merging behavior and metadata preservation. - Modify serialization logic in providers API to handle new model structure and sourceIndex. - Improve YAML AST handling in visual config tests to ensure accurate condition management. * feat(cooldown): implement cooldown reset functionality and UI updates * feat(authFileCard): add disabled state styling and update tests for clarity * refactor: reorganize logs feature structure and improve log handling - Deleted the `useLogScroller` hook and its associated logic to streamline log scrolling functionality. - Moved `LogsPage` component to a new directory structure under `features/logs`. - Updated API service for logs to enhance error handling and response normalization. - Introduced new tests for log buffer, log parsing, and log selectors to ensure robust functionality. - Added localization tests to verify translation consistency across multiple languages. - Improved log request handling and lifecycle management in the logs controller. - Enhanced log scrolling tests to validate anchor adjustments and pending log line counts. * feat(logs): enhance log viewer with line wrapping and improved filter options - Added a toggle for wrapping long log lines in the LogsPage component. - Updated the structured filters panel to open in a modal for better accessibility. - Improved accessibility features with appropriate ARIA attributes. - Refactored log rendering logic to support new wrapping functionality. - Updated translations for filter and display settings in multiple languages. - Enhanced styles for better layout and responsiveness in log viewer. - Added tests to ensure layout integrity and functionality of new features. * feat(logs): update styles for improved layout and toolbar controls * feat(i18n): add legacy backend error messages in Chinese and Traditional Chinese feat(api): implement config patching logic for managing configuration changes feat(api): add legacy backend probe for diagnosing v0 management support fix(auth): handle legacy backend detection during login process test: add tests for config patching and legacy backend probe functionality test: implement visual config rebase tests for maintaining draft state * fix(config): recover confirmed list edits without replaying stale indexes * fix(config): block conflicting payload replacements after recovery * fix(logs): respect handled Escape events in fullscreen * fix(quota): load xAI subscription after quota and preserve remaining amounts --------- Co-authored-by: Supra4E8C --- src/features/quota/hooks/useQuotaActions.ts | 5 +- .../quota/hooks/useQuotaBatchLoader.ts | 11 +- src/features/quota/providers/index.ts | 1 + src/features/quota/providers/types.ts | 2 + .../quota/providers/xai/XaiQuotaBody.tsx | 59 ++++++++- src/features/quota/providers/xai/data.ts | 61 +++++++++ src/features/quota/quotaEnrichment.ts | 38 ++++++ src/i18n/locales/en.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/i18n/locales/zh-TW.json | 2 + src/types/quota.ts | 6 + src/utils/quota/builders.ts | 24 ++++ src/utils/quota/constants.ts | 2 + tests/xaiPaidQuotaFallback.test.ts | 3 +- tests/xaiQuotaEnrichment.test.ts | 124 ++++++++++++++++++ tests/xaiQuotaUnavailable.test.ts | 66 ++++++++++ 17 files changed, 402 insertions(+), 8 deletions(-) create mode 100644 src/features/quota/quotaEnrichment.ts create mode 100644 tests/xaiQuotaEnrichment.test.ts diff --git a/src/features/quota/hooks/useQuotaActions.ts b/src/features/quota/hooks/useQuotaActions.ts index 7be118c3..3a63319d 100644 --- a/src/features/quota/hooks/useQuotaActions.ts +++ b/src/features/quota/hooks/useQuotaActions.ts @@ -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) { diff --git a/src/features/quota/hooks/useQuotaBatchLoader.ts b/src/features/quota/hooks/useQuotaBatchLoader.ts index 2efd2268..f9538b16 100644 --- a/src/features/quota/hooks/useQuotaBatchLoader.ts +++ b/src/features/quota/hooks/useQuotaBatchLoader.ts @@ -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(); 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 { diff --git a/src/features/quota/providers/index.ts b/src/features/quota/providers/index.ts index 796915a7..f8121210 100644 --- a/src/features/quota/providers/index.ts +++ b/src/features/quota/providers/index.ts @@ -38,6 +38,7 @@ export interface QuotaAdapter { i18nPrefix: string; filterFn: (file: AuthFileItem) => boolean; fetchQuota: (file: AuthFileItem, t: TFunction) => Promise; + enrichQuota?: (file: AuthFileItem, data: unknown, t: TFunction) => Promise; resetQuota?: (file: AuthFileItem, t: TFunction) => Promise; canResetQuota?: (quota: QuotaCardState) => boolean; storeSelector: (state: QuotaStore) => Record; diff --git a/src/features/quota/providers/types.ts b/src/features/quota/providers/types.ts index 5f9eb36b..6962137a 100644 --- a/src/features/quota/providers/types.ts +++ b/src/features/quota/providers/types.ts @@ -46,6 +46,8 @@ export interface QuotaProviderData { i18nPrefix: string; filterFn: (file: AuthFileItem) => boolean; fetchQuota: (file: AuthFileItem, t: TFunction) => Promise; + /** Optional details loaded only after the primary quota has been committed. */ + enrichQuota?: (file: AuthFileItem, data: TData, t: TFunction) => Promise; resetQuota?: (file: AuthFileItem, t: TFunction) => Promise; canResetQuota?: (quota: TState) => boolean; storeSelector: (state: QuotaStore) => Record; diff --git a/src/features/quota/providers/xai/XaiQuotaBody.tsx b/src/features/quota/providers/xai/XaiQuotaBody.tsx index 4f5c98f0..20bc3096 100644 --- a/src/features/quota/providers/xai/XaiQuotaBody.tsx +++ b/src/features/quota/providers/xai/XaiQuotaBody.tsx @@ -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['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) <>
{t('xai_quota.plan_label')} - {t('xai_quota.plan_paid')} + + {billing.planLabel ?? t('xai_quota.plan_paid')} +
{t('xai_quota.paid_health')}
@@ -138,13 +155,45 @@ export function XaiQuotaBody({ quota, classes }: QuotaBodyProps) 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) && (
- {t('xai_quota.plan_label')} - - {t(`xai_quota.${plan.labelKey}`)} + {subscriptionLabel && ( + + {t('xai_quota.plan_label')} + {subscriptionLabel} + + )} + {headerReset && ( + + {t('xai_quota.resets_label')} + {headerReset.absolute} + {headerReset.relative && ( + {headerReset.relative} + )} + + )} +
+ )} + {typeof billing.prepaidBalanceCents === 'number' && billing.prepaidBalanceCents > 0 && ( +
+ {t('xai_quota.prepaid')} + + {formatUsdFromCents(billing.prepaidBalanceCents)}
)} diff --git a/src/features/quota/providers/xai/data.ts b/src/features/quota/providers/xai/data.ts index aff5a472..23513bdd 100644 --- a/src/features/quota/providers/xai/data.ts +++ b/src/features/quota/providers/xai/data.ts @@ -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 | null => { return value && typeof value === 'object' && !Array.isArray(value) @@ -145,6 +149,58 @@ const requestXaiPaidHealth = async (authIndex: string): Promise { + 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 | 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 => { const rawAuthIndex = file['auth_index'] ?? file.authIndex; const authIndex = normalizeAuthIndex(rawAuthIndex); @@ -184,6 +240,11 @@ export const XAI_CONFIG: QuotaProviderData = { 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 }), diff --git a/src/features/quota/quotaEnrichment.ts b/src/features/quota/quotaEnrichment.ts new file mode 100644 index 00000000..79d9a6b6 --- /dev/null +++ b/src/features/quota/quotaEnrichment.ts @@ -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 { + 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. + } +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5800662c..2da507c7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -593,6 +593,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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 965d6588..a6b78711 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -596,6 +596,8 @@ "reset_at": "Сброс {{time}}", "product_usage": "Использование {{product}}", "plan_label": "План", + "resets_label": "Сброс", + "prepaid": "Предоплата", "plan_supergrok": "SuperGrok", "plan_supergrok_heavy": "SuperGrok Heavy", "plan_paid": "Платный", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e8cdbbd0..3d50ccee 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -591,6 +591,8 @@ "reset_at": "重置 {{time}}", "product_usage": "{{product}} 使用", "plan_label": "套餐", + "resets_label": "重置", + "prepaid": "预付", "plan_supergrok": "SuperGrok", "plan_supergrok_heavy": "SuperGrok Heavy", "plan_paid": "付费", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index f94353c8..088774d4 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -591,6 +591,8 @@ "reset_at": "重置 {{time}}", "product_usage": "{{product}} 使用", "plan_label": "方案", + "resets_label": "重置", + "prepaid": "預付", "plan_supergrok": "SuperGrok", "plan_supergrok_heavy": "SuperGrok Heavy", "plan_paid": "付費", diff --git a/src/types/quota.ts b/src/types/quota.ts index f2c0c228..ad466ca2 100644 --- a/src/types/quota.ts +++ b/src/types/quota.ts @@ -387,6 +387,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; @@ -422,6 +424,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; diff --git a/src/utils/quota/builders.ts b/src/utils/quota/builders.ts index f771a034..e186fb92 100644 --- a/src/utils/quota/builders.ts +++ b/src/utils/quota/builders.ts @@ -414,6 +414,7 @@ const emptyXaiBillingSummary = (): XaiBillingSummary => ({ onDemandCapCents: null, onDemandUsedCents: null, onDemandUsedPercent: null, + prepaidBalanceCents: null, usedPercent: null, }); @@ -438,6 +439,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 { @@ -484,6 +501,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 @@ -517,6 +537,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; @@ -566,6 +587,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, diff --git a/src/utils/quota/constants.ts b/src/utils/quota/constants.ts index 5647a128..2784ab1a 100644 --- a/src/utils/quota/constants.ts +++ b/src/utils/quota/constants.ts @@ -153,6 +153,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'; diff --git a/tests/xaiPaidQuotaFallback.test.ts b/tests/xaiPaidQuotaFallback.test.ts index 8bf6d8c8..b26449cf 100644 --- a/tests/xaiPaidQuotaFallback.test.ts +++ b/tests/xaiPaidQuotaFallback.test.ts @@ -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, diff --git a/tests/xaiQuotaEnrichment.test.ts b/tests/xaiQuotaEnrichment.test.ts new file mode 100644 index 00000000..fc238159 --- /dev/null +++ b/tests/xaiQuotaEnrichment.test.ts @@ -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((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'); +}); diff --git a/tests/xaiQuotaUnavailable.test.ts b/tests/xaiQuotaUnavailable.test.ts index 1af3181f..35ef711c 100644 --- a/tests/xaiQuotaUnavailable.test.ts +++ b/tests/xaiQuotaUnavailable.test.ts @@ -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()));