diff --git a/src/components/quota/index.ts b/src/components/quota/index.ts index 079e8e3..c46a954 100644 --- a/src/components/quota/index.ts +++ b/src/components/quota/index.ts @@ -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'; diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts index 8f46711..1908520 100644 --- a/src/components/quota/quotaConfigs.ts +++ b/src/components/quota/quotaConfigs.ts @@ -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 | ((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(); 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; geminiCliQuota: Record; kimiQuota: Record; + xaiQuota: Record; setAntigravityQuota: (updater: QuotaUpdater>) => void; setClaudeQuota: (updater: QuotaUpdater>) => void; setCodexQuota: (updater: QuotaUpdater>) => void; setGeminiCliQuota: (updater: QuotaUpdater>) => void; setKimiQuota: (updater: QuotaUpdater>) => void; + setXaiQuota: (updater: QuotaUpdater>) => 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 => { +const fetchKimiQuota = async (file: AuthFileItem, t: TFunction): Promise => { 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) - : 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 => { + 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 = { type: 'kimi', i18nPrefix: 'kimi_quota', @@ -1352,3 +1525,26 @@ export const KIMI_CONFIG: QuotaConfig = { gridClassName: styles.kimiGrid, renderQuotaItems: renderKimiItems, }; + +export const XAI_CONFIG: QuotaConfig = { + 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, +}; diff --git a/src/features/authFiles/components/AuthFileCard.tsx b/src/features/authFiles/components/AuthFileCard.tsx index 662b756..4736300 100644 --- a/src/features/authFiles/components/AuthFileCard.tsx +++ b/src/features/authFiles/components/AuthFileCard.tsx @@ -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); diff --git a/src/features/authFiles/components/AuthFileQuotaSection.tsx b/src/features/authFiles/components/AuthFileQuotaSection.tsx index d5cbc46..2a0fbeb 100644 --- a/src/features/authFiles/components/AuthFileQuotaSection.tsx +++ b/src/features/authFiles/components/AuthFileQuotaSection.tsx @@ -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) => ({ ...prev, - [file.name]: config.buildLoadingState() + [file.name]: config.buildLoadingState(), })); try { const data = await config.fetchQuota(file, t); updateQuotaState((prev: Record) => ({ ...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) => ({ ...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' ? (
{t(`${config.i18nPrefix}.load_failed`, { - message: quotaErrorMessage + message: quotaErrorMessage, })}
) : quota ? ( diff --git a/src/features/authFiles/constants.ts b/src/features/authFiles/constants.ts index 4a3a61d..7a7fdcc 100644 --- a/src/features/authFiles/constants.ts +++ b/src/features/authFiles/constants.ts @@ -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([ 'antigravity', @@ -32,6 +32,7 @@ export const QUOTA_PROVIDER_TYPES = new Set([ '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(); }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7f48feb..4979714 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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-.json using the same rules as the CLI vertex-import helper.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 66a18fa..ca104cd 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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-.json по тем же правилам, что и помощник CLI vertex-import.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6a0d1ce..c80c06a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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-.json。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 15ea873..30dca44 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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-.json。", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 4b62686..59fa5ad 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -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; diff --git a/src/pages/QuotaPage.module.scss b/src/pages/QuotaPage.module.scss index 0d65244..2ba5ad4 100644 --- a/src/pages/QuotaPage.module.scss +++ b/src/pages/QuotaPage.module.scss @@ -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); diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index cdca9d2..ad9b723 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -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} /> + = T | ((prev: T) => T); @@ -13,15 +20,17 @@ interface QuotaStoreState { codexQuota: Record; geminiCliQuota: Record; kimiQuota: Record; + xaiQuota: Record; setAntigravityQuota: (updater: QuotaUpdater>) => void; setClaudeQuota: (updater: QuotaUpdater>) => void; setCodexQuota: (updater: QuotaUpdater>) => void; setGeminiCliQuota: (updater: QuotaUpdater>) => void; setKimiQuota: (updater: QuotaUpdater>) => void; + setXaiQuota: (updater: QuotaUpdater>) => void; clearQuotaCache: () => void; } -const resolveUpdater = (updater: QuotaUpdater, prev: T): T => { +const resolveUpdater = (updater: QuotaUpdater, prev: T): T => { if (typeof updater === 'function') { return (updater as (value: T) => T)(prev); } @@ -34,25 +43,30 @@ export const useQuotaStore = create((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((set) => ({ claudeQuota: {}, codexQuota: {}, geminiCliQuota: {}, - kimiQuota: {} - }) + kimiQuota: {}, + xaiQuota: {}, + }), })); diff --git a/src/types/quota.ts b/src/types/quota.ts index 7b722dd..0370a86 100644 --- a/src/types/quota.ts +++ b/src/types/quota.ts @@ -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; +} diff --git a/src/utils/quota/constants.ts b/src/utils/quota/constants.ts index 96e01c7..707f543 100644 --- a/src/utils/quota/constants.ts +++ b/src/utils/quota/constants.ts @@ -42,6 +42,10 @@ export const TYPE_COLORS: Record = { 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$', +}; diff --git a/src/utils/quota/parsers.ts b/src/utils/quota/parsers.ts index aef4799..6f6eaab 100644 --- a/src/utils/quota/parsers.ts +++ b/src/utils/quota/parsers.ts @@ -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; +} diff --git a/src/utils/quota/validators.ts b/src/utils/quota/validators.ts index 18097e4..4213f1f 100644 --- a/src/utils/quota/validators.ts +++ b/src/utils/quota/validators.ts @@ -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) : 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;