feat: add xAI/Grok quota management

- Introduced new quota management for xAI/Grok, including types, API calls, and UI components.
- Added localization support for xAI quota in English, Russian, Chinese (Simplified and Traditional).
- Updated styles for xAI quota cards and sections.
- Integrated xAI quota fetching and rendering logic into existing quota management system.
- Enhanced auth file handling to support xAI files.
This commit is contained in:
LTbinglingfeng
2026-05-24 01:08:04 +08:00
Unverified
parent a44bcd3337
commit 9a5c2b0ea4
17 changed files with 466 additions and 82 deletions
+8 -1
View File
@@ -5,5 +5,12 @@
export { QuotaSection } from './QuotaSection';
export { QuotaCard } from './QuotaCard';
export { useQuotaLoader } from './useQuotaLoader';
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIMI_CONFIG } from './quotaConfigs';
export {
ANTIGRAVITY_CONFIG,
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CLI_CONFIG,
KIMI_CONFIG,
XAI_CONFIG,
} from './quotaConfigs';
export type { QuotaConfig } from './quotaConfigs';
+230 -34
View File
@@ -28,6 +28,9 @@ import type {
GeminiCliUserTier,
KimiQuotaRow,
KimiQuotaState,
XaiBillingConfig,
XaiBillingSummary,
XaiQuotaState,
} from '@/types';
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import { useQuotaStore } from '@/stores';
@@ -45,6 +48,8 @@ import {
GEMINI_CLI_REQUEST_HEADERS,
KIMI_USAGE_URL,
KIMI_REQUEST_HEADERS,
XAI_BILLING_URL,
XAI_REQUEST_HEADERS,
normalizeGeminiCliModelId,
normalizeNumberValue,
normalizePlanType,
@@ -56,6 +61,7 @@ import {
parseGeminiCliQuotaPayload,
parseGeminiCliCodeAssistPayload,
parseKimiUsagePayload,
parseXaiBillingPayload,
resolveCodexChatgptAccountId,
resolveCodexPlanType,
resolveGeminiCliProjectId,
@@ -74,6 +80,7 @@ import {
isGeminiCliFile,
isKimiFile,
isRuntimeOnlyAuthFile,
isXaiFile,
} from '@/utils/quota';
import { normalizeAuthIndex } from '@/utils/authIndex';
import type { QuotaRenderHelpers } from './QuotaCard';
@@ -81,7 +88,7 @@ import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi' | 'xai';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
const QUOTA_PROGRESS_HIGH_THRESHOLD = 70;
@@ -89,7 +96,12 @@ const QUOTA_PROGRESS_MEDIUM_THRESHOLD = 30;
const geminiCliSupplementaryRequestIds = new Map<string, number>();
const geminiCliSupplementaryCache = new Map<
string,
{ requestId: number; tierLabel: string | null; tierId: string | null; creditBalance: number | null }
{
requestId: number;
tierLabel: string | null;
tierId: string | null;
creditBalance: number | null;
}
>();
export interface QuotaStore {
@@ -98,11 +110,13 @@ export interface QuotaStore {
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
kimiQuota: Record<string, KimiQuotaState>;
xaiQuota: Record<string, XaiQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
setKimiQuota: (updater: QuotaUpdater<Record<string, KimiQuotaState>>) => void;
setXaiQuota: (updater: QuotaUpdater<Record<string, XaiQuotaState>>) => void;
clearQuotaCache: () => void;
}
@@ -233,12 +247,19 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
const WINDOW_META = {
codeFiveHour: { id: 'five-hour', labelKey: 'codex_quota.primary_window' },
codeWeekly: { id: 'weekly', labelKey: 'codex_quota.secondary_window' },
codeReviewFiveHour: { id: 'code-review-five-hour', labelKey: 'codex_quota.code_review_primary_window' },
codeReviewWeekly: { id: 'code-review-weekly', labelKey: 'codex_quota.code_review_secondary_window' },
codeReviewFiveHour: {
id: 'code-review-five-hour',
labelKey: 'codex_quota.code_review_primary_window',
},
codeReviewWeekly: {
id: 'code-review-weekly',
labelKey: 'codex_quota.code_review_secondary_window',
},
} as const;
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
const codeReviewLimit =
payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
const additionalRateLimits = payload.additional_rate_limits ?? payload.additionalRateLimits ?? [];
const windows: CodexQuotaWindow[] = [];
@@ -302,7 +323,8 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
fiveHourWindow = primaryWindow && primaryWindow !== weeklyWindow ? primaryWindow : null;
}
if (!weeklyWindow) {
weeklyWindow = secondaryWindow && secondaryWindow !== fiveHourWindow ? secondaryWindow : null;
weeklyWindow =
secondaryWindow && secondaryWindow !== fiveHourWindow ? secondaryWindow : null;
}
}
@@ -370,7 +392,8 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
const idPrefix = normalizeWindowId(limitName) || `additional-${index + 1}`;
const additionalPrimaryWindow = rateInfo.primary_window ?? rateInfo.primaryWindow ?? null;
const additionalSecondaryWindow = rateInfo.secondary_window ?? rateInfo.secondaryWindow ?? null;
const additionalSecondaryWindow =
rateInfo.secondary_window ?? rateInfo.secondaryWindow ?? null;
const additionalLimitReached = rateInfo.limit_reached ?? rateInfo.limitReached;
const additionalAllowed = rateInfo.allowed;
@@ -456,8 +479,7 @@ const resolveGeminiCliTierLabel = (
if (!payload) return null;
const currentTier: GeminiCliUserTier | null | undefined =
payload.currentTier ?? payload.current_tier;
const paidTier: GeminiCliUserTier | null | undefined =
payload.paidTier ?? payload.paid_tier;
const paidTier: GeminiCliUserTier | null | undefined = payload.paidTier ?? payload.paid_tier;
const rawId = normalizeStringValue(paidTier?.id) ?? normalizeStringValue(currentTier?.id);
if (!rawId) return null;
const tierId = rawId.toLowerCase();
@@ -465,14 +487,11 @@ const resolveGeminiCliTierLabel = (
return labelKey ? t(`gemini_cli_quota.${labelKey}`) : rawId;
};
const resolveGeminiCliTierId = (
payload: GeminiCliCodeAssistPayload | null
): string | null => {
const resolveGeminiCliTierId = (payload: GeminiCliCodeAssistPayload | null): string | null => {
if (!payload) return null;
const currentTier: GeminiCliUserTier | null | undefined =
payload.currentTier ?? payload.current_tier;
const paidTier: GeminiCliUserTier | null | undefined =
payload.paidTier ?? payload.paid_tier;
const paidTier: GeminiCliUserTier | null | undefined = payload.paidTier ?? payload.paid_tier;
const rawId = normalizeStringValue(paidTier?.id) ?? normalizeStringValue(currentTier?.id);
return rawId ? rawId.toLowerCase() : null;
};
@@ -481,14 +500,12 @@ const resolveGeminiCliCreditBalance = (
payload: GeminiCliCodeAssistPayload | null
): number | null => {
if (!payload) return null;
const paidTier: GeminiCliUserTier | null | undefined =
payload.paidTier ?? payload.paid_tier;
const paidTier: GeminiCliUserTier | null | undefined = payload.paidTier ?? payload.paid_tier;
const currentTier: GeminiCliUserTier | null | undefined =
payload.currentTier ?? payload.current_tier;
const tier = paidTier ?? currentTier;
if (!tier) return null;
const credits: GeminiCliCredits[] =
tier.availableCredits ?? tier.available_credits ?? [];
const credits: GeminiCliCredits[] = tier.availableCredits ?? tier.available_credits ?? [];
let total = 0;
let found = false;
for (const credit of credits) {
@@ -859,7 +876,11 @@ const renderGeminiCliItems = (
if (buckets.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'))
h(
'div',
{ key: 'empty', className: styleMap.quotaMessage },
t('gemini_cli_quota.empty_buckets')
)
);
return h(Fragment, null, ...nodes);
}
@@ -973,8 +994,12 @@ const resolveClaudePlanType = (profile: ClaudeProfileResponse | null): string |
const hasClaudePro = normalizeFlagValue(profile.account?.has_claude_pro);
if (hasClaudePro) return 'plan_pro';
const organizationType = normalizeStringValue(profile.organization?.organization_type)?.toLowerCase();
const subscriptionStatus = normalizeStringValue(profile.organization?.subscription_status)?.toLowerCase();
const organizationType = normalizeStringValue(
profile.organization?.organization_type
)?.toLowerCase();
const subscriptionStatus = normalizeStringValue(
profile.organization?.subscription_status
)?.toLowerCase();
if (organizationType === 'claude_team' && subscriptionStatus === 'active') {
return 'plan_team';
@@ -988,7 +1013,11 @@ const resolveClaudePlanType = (profile: ClaudeProfileResponse | null): string |
const fetchClaudeQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null; planType?: string | null }> => {
): Promise<{
windows: ClaudeQuotaWindow[];
extraUsage?: ClaudeExtraUsage | null;
planType?: string | null;
}> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndex(rawAuthIndex);
if (!authIndex) {
@@ -1217,7 +1246,13 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<
fetchQuota: fetchGeminiCliQuota,
storeSelector: (state) => state.geminiCliQuota,
storeSetter: 'setGeminiCliQuota',
buildLoadingState: () => ({ status: 'loading', buckets: [], tierLabel: null, tierId: null, creditBalance: null }),
buildLoadingState: () => ({
status: 'loading',
buckets: [],
tierLabel: null,
tierId: null,
creditBalance: null,
}),
buildSuccessState: (data) => {
const supplementarySnapshot = readGeminiCliSupplementarySnapshot(
data.fileName,
@@ -1245,10 +1280,7 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<
renderQuotaItems: renderGeminiCliItems,
};
const fetchKimiQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<KimiQuotaRow[]> => {
const fetchKimiQuota = async (file: AuthFileItem, t: TFunction): Promise<KimiQuotaRow[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndex(rawAuthIndex);
if (!authIndex) {
@@ -1299,7 +1331,7 @@ const renderKimiItems = (
const percentLabel = remaining === null ? '--' : `${remaining}%`;
const rowLabel = row.labelKey
? t(row.labelKey, (row.labelParams ?? {}) as Record<string, string | number>)
: row.label ?? '';
: (row.label ?? '');
const resetLabel = formatKimiResetHint(t, row.resetHint);
return h(
@@ -1313,12 +1345,8 @@ const renderKimiItems = (
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
limit > 0
? h('span', { className: styleMap.quotaAmount }, `${used} / ${limit}`)
: null,
resetLabel
? h('span', { className: styleMap.quotaReset }, resetLabel)
: null
limit > 0 ? h('span', { className: styleMap.quotaAmount }, `${used} / ${limit}`) : null,
resetLabel ? h('span', { className: styleMap.quotaReset }, resetLabel) : null
)
),
h(QuotaProgressBar, {
@@ -1330,6 +1358,151 @@ const renderKimiItems = (
});
};
const 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);
};
const buildXaiBillingSummary = (
config: XaiBillingConfig | null | undefined
): XaiBillingSummary | null => {
if (!config || typeof config !== 'object') return null;
const monthlyLimitCents = normalizeXaiCentValue(config.monthlyLimit ?? config.monthly_limit);
const usedCents = normalizeXaiCentValue(config.used);
const onDemandCapCents = normalizeXaiCentValue(config.onDemandCap ?? config.on_demand_cap);
const billingPeriodStart =
normalizeStringValue(config.billingPeriodStart ?? config.billing_period_start) ?? undefined;
const billingPeriodEnd =
normalizeStringValue(config.billingPeriodEnd ?? config.billing_period_end) ?? undefined;
if (
monthlyLimitCents === null &&
usedCents === null &&
onDemandCapCents === null &&
!billingPeriodEnd
) {
return null;
}
const usedPercent =
monthlyLimitCents !== null && monthlyLimitCents > 0 && usedCents !== null
? (usedCents / monthlyLimitCents) * 100
: null;
return {
monthlyLimitCents,
usedCents,
onDemandCapCents,
billingPeriodStart,
billingPeriodEnd,
usedPercent,
};
};
const fetchXaiQuota = async (file: AuthFileItem, t: TFunction): Promise<XaiBillingSummary> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndex(rawAuthIndex);
if (!authIndex) {
throw new Error(t('xai_quota.missing_auth_index'));
}
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: XAI_BILLING_URL,
header: { ...XAI_REQUEST_HEADERS },
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseXaiBillingPayload(result.body ?? result.bodyText);
const summary = buildXaiBillingSummary(payload?.config);
if (!summary) {
throw new Error(t('xai_quota.empty_data'));
}
return summary;
};
const formatUsdFromCents = (cents: number | null): string => {
if (cents === null) return '--';
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'USD',
}).format(cents / 100);
};
const formatXaiUsageAmount = (billing: XaiBillingSummary): string => {
const used = formatUsdFromCents(billing.usedCents);
const limit = formatUsdFromCents(billing.monthlyLimitCents);
if (billing.monthlyLimitCents === null) return used;
return `${used} / ${limit}`;
};
const renderXaiItems = (
quota: XaiQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h, Fragment } = React;
const billing = quota.billing;
if (!billing) {
return h('div', { className: styleMap.quotaMessage }, t('xai_quota.empty_data'));
}
const clampedUsed =
billing.usedPercent === null ? null : Math.max(0, Math.min(100, billing.usedPercent));
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const amountLabel = formatXaiUsageAmount(billing);
const resetLabel = formatQuotaResetTime(billing.billingPeriodEnd);
const onDemandCap = billing.onDemandCapCents ?? 0;
const payAsYouGoLabel =
onDemandCap > 0
? t('xai_quota.pay_as_you_go_enabled', { cap: formatUsdFromCents(onDemandCap) })
: t('xai_quota.pay_as_you_go_disabled');
return h(
Fragment,
null,
h(
'div',
{ key: 'pay-as-you-go', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('xai_quota.pay_as_you_go_label')),
h('span', { className: styleMap.codexPlanValue }, payAsYouGoLabel)
),
h(
'div',
{ key: 'monthly-credits', className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, t('xai_quota.monthly_credits')),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
h('span', { className: styleMap.quotaAmount }, amountLabel),
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, {
percent: remaining,
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
})
)
);
};
export const KIMI_CONFIG: QuotaConfig<KimiQuotaState, KimiQuotaRow[]> = {
type: 'kimi',
i18nPrefix: 'kimi_quota',
@@ -1352,3 +1525,26 @@ export const KIMI_CONFIG: QuotaConfig<KimiQuotaState, KimiQuotaRow[]> = {
gridClassName: styles.kimiGrid,
renderQuotaItems: renderKimiItems,
};
export const XAI_CONFIG: QuotaConfig<XaiQuotaState, XaiBillingSummary> = {
type: 'xai',
i18nPrefix: 'xai_quota',
cardIdleMessageKey: 'quota_management.card_idle_hint',
filterFn: (file) => isXaiFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchXaiQuota,
storeSelector: (state) => state.xaiQuota,
storeSetter: 'setXaiQuota',
buildLoadingState: () => ({ status: 'loading', billing: null }),
buildSuccessState: (billing) => ({ status: 'success', billing }),
buildErrorState: (message, status) => ({
status: 'error',
billing: null,
error: message,
errorStatus: status,
}),
cardClassName: styles.xaiCard,
controlsClassName: styles.xaiControls,
controlClassName: styles.xaiControl,
gridClassName: styles.xaiGrid,
renderQuotaItems: renderXaiItems,
};
@@ -112,7 +112,9 @@ export function AuthFileCard(props: AuthFileCardProps) {
? styles.geminiCliCard
: quotaType === 'kimi'
? styles.kimiCard
: '';
: quotaType === 'xai'
? styles.xaiCard
: '';
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeRecentRequestAuthIndex(rawAuthIndex);
@@ -6,7 +6,8 @@ import {
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CLI_CONFIG,
KIMI_CONFIG
KIMI_CONFIG,
XAI_CONFIG,
} from '@/components/quota';
import { useNotificationStore, useQuotaStore } from '@/stores';
import type { AuthFileItem } from '@/types';
@@ -14,7 +15,7 @@ import { getStatusFromError } from '@/utils/quota';
import {
isRuntimeOnlyAuthFile,
resolveQuotaErrorMessage,
type QuotaProviderType
type QuotaProviderType,
} from '@/features/authFiles/constants';
import { QuotaProgressBar } from '@/features/authFiles/components/QuotaProgressBar';
import styles from '@/pages/AuthFilesPage.module.scss';
@@ -26,6 +27,7 @@ const getQuotaConfig = (type: QuotaProviderType) => {
if (type === 'claude') return CLAUDE_CONFIG;
if (type === 'codex') return CODEX_CONFIG;
if (type === 'kimi') return KIMI_CONFIG;
if (type === 'xai') return XAI_CONFIG;
return GEMINI_CLI_CONFIG;
};
@@ -45,14 +47,18 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
if (quotaType === 'claude') return state.claudeQuota[file.name] as QuotaState;
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState;
if (quotaType === 'kimi') return state.kimiQuota[file.name] as QuotaState;
if (quotaType === 'xai') return state.xaiQuota[file.name] as QuotaState;
return state.geminiCliQuota[file.name] as QuotaState;
});
const updateQuotaState = useQuotaStore((state) => {
if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void;
if (quotaType === 'claude') return state.setClaudeQuota as unknown as (updater: unknown) => void;
if (quotaType === 'antigravity')
return state.setAntigravityQuota as unknown as (updater: unknown) => void;
if (quotaType === 'claude')
return state.setClaudeQuota as unknown as (updater: unknown) => void;
if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void;
if (quotaType === 'kimi') return state.setKimiQuota as unknown as (updater: unknown) => void;
if (quotaType === 'xai') return state.setXaiQuota as unknown as (updater: unknown) => void;
return state.setGeminiCliQuota as unknown as (updater: unknown) => void;
});
@@ -73,14 +79,14 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
updateQuotaState((prev: Record<string, unknown>) => ({
...prev,
[file.name]: config.buildLoadingState()
[file.name]: config.buildLoadingState(),
}));
try {
const data = await config.fetchQuota(file, t);
updateQuotaState((prev: Record<string, unknown>) => ({
...prev,
[file.name]: config.buildSuccessState(data)
[file.name]: config.buildSuccessState(data),
}));
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
} catch (err: unknown) {
@@ -88,7 +94,7 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
const status = getStatusFromError(err);
updateQuotaState((prev: Record<string, unknown>) => ({
...prev,
[file.name]: config.buildErrorState(message, status)
[file.name]: config.buildErrorState(message, status),
}));
showNotification(t('auth_files.quota_refresh_failed', { name: file.name, message }), 'error');
}
@@ -123,7 +129,7 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${config.i18nPrefix}.load_failed`, {
message: quotaErrorMessage
message: quotaErrorMessage,
})}
</div>
) : quota ? (
+3 -2
View File
@@ -24,7 +24,7 @@ export type AuthFileModelItem = {
};
export type AuthFileIconAsset = string | { light: string; dark: string };
export type QuotaProviderType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
export type QuotaProviderType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi' | 'xai';
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>([
'antigravity',
@@ -32,6 +32,7 @@ export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>([
'codex',
'gemini-cli',
'kimi',
'xai',
]);
export const MIN_CARD_PAGE_SIZE = 3;
@@ -247,7 +248,7 @@ export const formatModified = (item: AuthFileItem): string => {
const date =
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
: parseTimestamp(raw) ?? new Date(String(raw));
: (parseTimestamp(raw) ?? new Date(String(raw)));
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
};
+16
View File
@@ -726,6 +726,22 @@
"limit_index": "Limit #{{index}}",
"reset_hint": "resets in {{hint}}"
},
"xai_quota": {
"title": "Grok Quota",
"empty_title": "No xAI/Grok Auth Files",
"empty_desc": "Log in with xAI OAuth to view Grok quota.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"empty_data": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"monthly_credits": "Monthly credits",
"pay_as_you_go_label": "Pay as you go",
"pay_as_you_go_enabled": "Enabled, cap {{cap}}",
"pay_as_you_go_disabled": "Disabled"
},
"vertex_import": {
"title": "Vertex JSON Login",
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
+16
View File
@@ -723,6 +723,22 @@
"limit_index": "Лимит #{{index}}",
"reset_hint": "сброс через {{hint}}"
},
"xai_quota": {
"title": "Квота Grok",
"empty_title": "Файлы авторизации xAI/Grok отсутствуют",
"empty_desc": "Войдите через xAI OAuth, чтобы увидеть квоту Grok.",
"idle": "Не загружено. Нажмите \"Обновить квоту\".",
"loading": "Загрузка квоты...",
"load_failed": "Не удалось загрузить квоту: {{message}}",
"missing_auth_index": "В файле авторизации отсутствует auth_index",
"empty_data": "Данные по квоте отсутствуют",
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все",
"monthly_credits": "Месячные кредиты",
"pay_as_you_go_label": "Оплата по факту",
"pay_as_you_go_enabled": "Включено, лимит {{cap}}",
"pay_as_you_go_disabled": "Отключено"
},
"vertex_import": {
"title": "Вход с Vertex JSON",
"description": "Загрузите JSON ключа сервисного аккаунта Google, чтобы сохранить его как auth-dir/vertex-<project>.json по тем же правилам, что и помощник CLI vertex-import.",
+16
View File
@@ -726,6 +726,22 @@
"limit_index": "限额 #{{index}}",
"reset_hint": "{{hint}} 后重置"
},
"xai_quota": {
"title": "Grok 额度",
"empty_title": "暂无 xAI/Grok 认证",
"empty_desc": "使用 xAI OAuth 登录后即可查看 Grok 额度。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"empty_data": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"monthly_credits": "月度积分",
"pay_as_you_go_label": "按量付费",
"pay_as_you_go_enabled": "已启用,封顶 {{cap}}",
"pay_as_you_go_disabled": "未启用"
},
"vertex_import": {
"title": "Vertex JSON 登录",
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
+16
View File
@@ -726,6 +726,22 @@
"limit_index": "限額 #{{index}}",
"reset_hint": "{{hint}} 後重置"
},
"xai_quota": {
"title": "Grok 配額",
"empty_title": "暫無 xAI/Grok 驗證",
"empty_desc": "使用 xAI OAuth 登入後即可查看 Grok 配額。",
"idle": "點擊此處重新整理配額",
"loading": "正在載入配額...",
"load_failed": "配額取得失敗:{{message}}",
"missing_auth_index": "驗證檔案缺少 auth_index",
"empty_data": "暫無配額資料",
"refresh_button": "重新整理配額",
"fetch_all": "取得全部",
"monthly_credits": "月度點數",
"pay_as_you_go_label": "按量付費",
"pay_as_you_go_enabled": "已啟用,封頂 {{cap}}",
"pay_as_you_go_disabled": "未啟用"
},
"vertex_import": {
"title": "Vertex JSON 登入",
"description": "上傳 Google 服務帳號 JSON,使用 CLI vertex-import 同步規則寫入 auth-dir/vertex-<project>.json。",
+4
View File
@@ -517,6 +517,10 @@
background-image: linear-gradient(180deg, rgba(220, 232, 255, 0.08), transparent);
}
.xaiCard {
background-image: linear-gradient(180deg, rgba(243, 244, 246, 0.08), transparent);
}
.quotaSection {
display: flex;
flex-direction: column;
+19 -19
View File
@@ -114,7 +114,8 @@
.claudeGrid,
.codexGrid,
.geminiCliGrid,
.kimiGrid {
.kimiGrid,
.xaiGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
@@ -128,7 +129,8 @@
.claudeControls,
.codexControls,
.geminiCliControls,
.kimiControls {
.kimiControls,
.xaiControls {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
@@ -140,7 +142,8 @@
.claudeControl,
.codexControl,
.geminiCliControl,
.kimiControl {
.kimiControl,
.xaiControl {
display: flex;
flex-direction: column;
gap: 4px;
@@ -247,33 +250,27 @@
// 卡片渐变背景 — 基于 TYPE_COLORS light.bg 转 rgba
.claudeCard {
background-image: linear-gradient(180deg,
rgba(251, 236, 228, 0.18),
rgba(251, 236, 228, 0));
background-image: linear-gradient(180deg, rgba(251, 236, 228, 0.18), rgba(251, 236, 228, 0));
}
.antigravityCard {
background-image: linear-gradient(180deg,
rgba(224, 247, 250, 0.12),
rgba(224, 247, 250, 0));
background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
}
.codexCard {
background-image: linear-gradient(180deg,
rgba(234, 231, 255, 0.18),
rgba(234, 231, 255, 0));
background-image: linear-gradient(180deg, rgba(234, 231, 255, 0.18), rgba(234, 231, 255, 0));
}
.geminiCliCard {
background-image: linear-gradient(180deg,
rgba(224, 232, 255, 0.2),
rgba(224, 232, 255, 0));
background-image: linear-gradient(180deg, rgba(224, 232, 255, 0.2), rgba(224, 232, 255, 0));
}
.kimiCard {
background-image: linear-gradient(180deg,
rgba(220, 232, 255, 0.2),
rgba(220, 232, 255, 0));
background-image: linear-gradient(180deg, rgba(220, 232, 255, 0.2), rgba(220, 232, 255, 0));
}
.xaiCard {
background-image: linear-gradient(180deg, rgba(243, 244, 246, 0.22), rgba(243, 244, 246, 0));
}
.quotaSection {
@@ -636,7 +633,10 @@
display: flex;
flex-direction: column;
gap: $spacing-sm;
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
transition:
transform $transition-fast,
box-shadow $transition-fast,
border-color $transition-fast;
&:hover {
transform: translateY(-2px);
+8 -1
View File
@@ -13,7 +13,8 @@ import {
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CLI_CONFIG,
KIMI_CONFIG
KIMI_CONFIG,
XAI_CONFIG,
} from '@/components/quota';
import type { AuthFileItem } from '@/types';
import styles from './QuotaPage.module.scss';
@@ -89,6 +90,12 @@ export function QuotaPage() {
loading={loading}
disabled={disableControls}
/>
<QuotaSection
config={XAI_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
<QuotaSection
config={GEMINI_CLI_CONFIG}
files={files}
+24 -9
View File
@@ -3,7 +3,14 @@
*/
import { create } from 'zustand';
import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState, KimiQuotaState } from '@/types';
import type {
AntigravityQuotaState,
ClaudeQuotaState,
CodexQuotaState,
GeminiCliQuotaState,
KimiQuotaState,
XaiQuotaState,
} from '@/types';
type QuotaUpdater<T> = T | ((prev: T) => T);
@@ -13,15 +20,17 @@ interface QuotaStoreState {
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
kimiQuota: Record<string, KimiQuotaState>;
xaiQuota: Record<string, XaiQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
setKimiQuota: (updater: QuotaUpdater<Record<string, KimiQuotaState>>) => void;
setXaiQuota: (updater: QuotaUpdater<Record<string, XaiQuotaState>>) => void;
clearQuotaCache: () => void;
}
const resolveUpdater = <T,>(updater: QuotaUpdater<T>, prev: T): T => {
const resolveUpdater = <T>(updater: QuotaUpdater<T>, prev: T): T => {
if (typeof updater === 'function') {
return (updater as (value: T) => T)(prev);
}
@@ -34,25 +43,30 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
codexQuota: {},
geminiCliQuota: {},
kimiQuota: {},
xaiQuota: {},
setAntigravityQuota: (updater) =>
set((state) => ({
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
antigravityQuota: resolveUpdater(updater, state.antigravityQuota),
})),
setClaudeQuota: (updater) =>
set((state) => ({
claudeQuota: resolveUpdater(updater, state.claudeQuota)
claudeQuota: resolveUpdater(updater, state.claudeQuota),
})),
setCodexQuota: (updater) =>
set((state) => ({
codexQuota: resolveUpdater(updater, state.codexQuota)
codexQuota: resolveUpdater(updater, state.codexQuota),
})),
setGeminiCliQuota: (updater) =>
set((state) => ({
geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota)
geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota),
})),
setKimiQuota: (updater) =>
set((state) => ({
kimiQuota: resolveUpdater(updater, state.kimiQuota)
kimiQuota: resolveUpdater(updater, state.kimiQuota),
})),
setXaiQuota: (updater) =>
set((state) => ({
xaiQuota: resolveUpdater(updater, state.xaiQuota),
})),
clearQuotaCache: () =>
set({
@@ -60,6 +74,7 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {},
kimiQuota: {}
})
kimiQuota: {},
xaiQuota: {},
}),
}));
+37
View File
@@ -306,3 +306,40 @@ export interface KimiQuotaState {
error?: string;
errorStatus?: number;
}
// xAI/Grok API payload types
export interface XaiBillingCent {
val?: number | string;
}
export interface XaiBillingConfig {
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;
billingPeriodStart?: string;
billing_period_start?: string;
billingPeriodEnd?: string;
billing_period_end?: string;
}
export interface XaiBillingPayload {
config?: XaiBillingConfig | null;
}
export interface XaiBillingSummary {
monthlyLimitCents: number | null;
usedCents: number | null;
onDemandCapCents: number | null;
billingPeriodStart?: string;
billingPeriodEnd?: string;
usedPercent: number | null;
}
export interface XaiQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
billing: XaiBillingSummary | null;
error?: string;
errorStatus?: number;
}
+16 -1
View File
@@ -42,6 +42,10 @@ export const TYPE_COLORS: Record<string, TypeColorSet> = {
light: { bg: '#e0f7fa', text: '#006064' },
dark: { bg: '#004d40', text: '#80deea' },
},
xai: {
light: { bg: '#f3f4f6', text: '#111827', border: '1px solid #d1d5db' },
dark: { bg: '#111827', text: '#f9fafb', border: '1px solid #374151' },
},
iflow: {
light: { bg: '#f5e3fc', text: '#9025c8' },
dark: { bg: '#521490', text: '#d49cf5' },
@@ -176,7 +180,11 @@ export const CLAUDE_REQUEST_HEADERS = {
export const CLAUDE_USAGE_WINDOW_KEYS = [
{ key: 'five_hour', id: 'five-hour', labelKey: 'claude_quota.five_hour' },
{ key: 'seven_day', id: 'seven-day', labelKey: 'claude_quota.seven_day' },
{ key: 'seven_day_oauth_apps', id: 'seven-day-oauth-apps', labelKey: 'claude_quota.seven_day_oauth_apps' },
{
key: 'seven_day_oauth_apps',
id: 'seven-day-oauth-apps',
labelKey: 'claude_quota.seven_day_oauth_apps',
},
{ key: 'seven_day_opus', id: 'seven-day-opus', labelKey: 'claude_quota.seven_day_opus' },
{ key: 'seven_day_sonnet', id: 'seven-day-sonnet', labelKey: 'claude_quota.seven_day_sonnet' },
{ key: 'seven_day_cowork', id: 'seven-day-cowork', labelKey: 'claude_quota.seven_day_cowork' },
@@ -198,3 +206,10 @@ export const KIMI_USAGE_URL = 'https://api.kimi.com/coding/v1/usages';
export const KIMI_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$',
};
// xAI/Grok API configuration
export const XAI_BILLING_URL = 'https://cli-chat-proxy.grok.com/v1/billing';
export const XAI_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$',
};
+28 -2
View File
@@ -2,7 +2,14 @@
* Normalization and parsing functions for quota data.
*/
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliCodeAssistPayload, GeminiCliQuotaPayload, KimiUsagePayload } from '@/types';
import type {
ClaudeUsagePayload,
CodexUsagePayload,
GeminiCliCodeAssistPayload,
GeminiCliQuotaPayload,
KimiUsagePayload,
XaiBillingPayload,
} from '@/types';
import { normalizeAuthIndex } from '@/utils/authIndex';
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
@@ -191,7 +198,9 @@ export function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayl
return null;
}
export function parseGeminiCliCodeAssistPayload(payload: unknown): GeminiCliCodeAssistPayload | null {
export function parseGeminiCliCodeAssistPayload(
payload: unknown
): GeminiCliCodeAssistPayload | null {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {
const trimmed = payload.trim();
@@ -224,3 +233,20 @@ export function parseKimiUsagePayload(payload: unknown): KimiUsagePayload | null
}
return null;
}
export function parseXaiBillingPayload(payload: unknown): XaiBillingPayload | null {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {
const trimmed = payload.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as XaiBillingPayload;
} catch {
return null;
}
}
if (typeof payload === 'object') {
return payload as XaiBillingPayload;
}
return null;
}
+8 -4
View File
@@ -7,7 +7,9 @@ import { GEMINI_CLI_IGNORED_MODEL_PREFIXES } from './constants';
export function resolveAuthProvider(file: AuthFileItem): string {
const raw = file.provider ?? file.type ?? '';
return String(raw).trim().toLowerCase();
const key = String(raw).trim().toLowerCase().replace(/_/g, '-');
if (key === 'x-ai' || key === 'grok') return 'xai';
return key;
}
export function isAntigravityFile(file: AuthFileItem): boolean {
@@ -25,9 +27,7 @@ export function isClaudeOAuthFile(file: AuthFileItem): boolean {
? (file.metadata as Record<string, unknown>)
: null;
const accessToken =
metadata && typeof metadata.access_token === 'string'
? metadata.access_token.trim()
: '';
metadata && typeof metadata.access_token === 'string' ? metadata.access_token.trim() : '';
return accessToken.includes('sk-ant-oat');
}
@@ -43,6 +43,10 @@ export function isKimiFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'kimi';
}
export function isXaiFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'xai';
}
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
const raw = file['runtime_only'] ?? file.runtimeOnly;
if (typeof raw === 'boolean') return raw;