From bdc379c3003424dc0ffec72fd9f40384cc5d7894 Mon Sep 17 00:00:00 2001 From: seakee Date: Thu, 14 May 2026 10:49:32 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(quota):=20sha?= =?UTF-8?q?re=20codex=20quota=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared Codex quota request, parsing, and rate-limit helpers. Move quota page and Codex inspection onto the shared path. Keeps existing behavior while reducing future drift across callers. --- src/components/quota/quotaConfigs.ts | 281 +++++---------------- src/features/monitoring/codexInspection.ts | 210 ++++++++------- src/services/api/codexQuota.test.ts | 25 ++ src/services/api/codexQuota.ts | 73 ++++++ src/services/api/index.ts | 1 + src/utils/quota/codexQuota.test.ts | 106 ++++++++ src/utils/quota/codexQuota.ts | 233 +++++++++++++++++ src/utils/quota/index.ts | 1 + 8 files changed, 598 insertions(+), 332 deletions(-) create mode 100644 src/services/api/codexQuota.test.ts create mode 100644 src/services/api/codexQuota.ts create mode 100644 src/utils/quota/codexQuota.test.ts create mode 100644 src/utils/quota/codexQuota.ts diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts index 0d589c217..6883a824e 100644 --- a/src/components/quota/quotaConfigs.ts +++ b/src/components/quota/quotaConfigs.ts @@ -15,9 +15,7 @@ import type { ClaudeQuotaState, ClaudeQuotaWindow, ClaudeUsagePayload, - CodexRateLimitInfo, CodexQuotaState, - CodexUsageWindow, CodexQuotaWindow, CodexUsagePayload, GeminiCliCodeAssistPayload, @@ -29,7 +27,12 @@ import type { KimiQuotaRow, KimiQuotaState, } from '@/types'; -import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api'; +import { + apiCallApi, + authFilesApi, + getApiCallErrorMessage, + requestCodexUsageRaw, +} from '@/services/api'; import { useQuotaStore } from '@/stores'; import { ANTIGRAVITY_QUOTA_URLS, @@ -38,8 +41,6 @@ import { CLAUDE_USAGE_URL, CLAUDE_REQUEST_HEADERS, CLAUDE_USAGE_WINDOW_KEYS, - CODEX_USAGE_URL, - CODEX_REQUEST_HEADERS, GEMINI_CLI_QUOTA_URL, GEMINI_CLI_CODE_ASSIST_URL, GEMINI_CLI_REQUEST_HEADERS, @@ -52,17 +53,16 @@ import { normalizeStringValue, parseAntigravityPayload, parseClaudeUsagePayload, - parseCodexUsagePayload, parseGeminiCliQuotaPayload, parseGeminiCliCodeAssistPayload, parseKimiUsagePayload, resolveCodexChatgptAccountId, resolveCodexPlanType, resolveGeminiCliProjectId, - formatCodexResetLabel, formatQuotaResetTime, formatKimiResetHint, buildAntigravityQuotaGroups, + buildCodexQuotaWindowInfos, buildGeminiCliQuotaBuckets, buildKimiQuotaRows, createStatusError, @@ -90,7 +90,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 { @@ -230,176 +235,15 @@ const fetchAntigravityQuota = async ( throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus); }; -const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => { - const FIVE_HOUR_SECONDS = 18000; - const WEEK_SECONDS = 604800; - 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' }, - } as const; - - const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined; - const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined; - const additionalRateLimits = payload.additional_rate_limits ?? payload.additionalRateLimits ?? []; - const windows: CodexQuotaWindow[] = []; - - const addWindow = ( - id: string, - label: string, - labelKey: string | undefined, - labelParams: Record | undefined, - window?: CodexUsageWindow | null, - limitReached?: boolean, - allowed?: boolean - ) => { - if (!window) return; - const resetLabel = formatCodexResetLabel(window); - const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent); - const isLimitReached = Boolean(limitReached) || allowed === false; - const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null); - windows.push({ - id, - label, - labelKey, - labelParams, - usedPercent, - resetLabel, - }); - }; - - const getWindowSeconds = (window?: CodexUsageWindow | null): number | null => { - if (!window) return null; - return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds); - }; - - const rawLimitReached = rateLimit?.limit_reached ?? rateLimit?.limitReached; - const rawAllowed = rateLimit?.allowed; - - const pickClassifiedWindows = ( - limitInfo?: CodexRateLimitInfo | null, - options?: { allowOrderFallback?: boolean } - ): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => { - const allowOrderFallback = options?.allowOrderFallback ?? true; - const primaryWindow = limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null; - const secondaryWindow = limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null; - const rawWindows = [primaryWindow, secondaryWindow]; - - let fiveHourWindow: CodexUsageWindow | null = null; - let weeklyWindow: CodexUsageWindow | null = null; - - for (const window of rawWindows) { - if (!window) continue; - const seconds = getWindowSeconds(window); - if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) { - fiveHourWindow = window; - } else if (seconds === WEEK_SECONDS && !weeklyWindow) { - weeklyWindow = window; - } - } - - // For legacy payloads without window duration, fallback to primary/secondary ordering. - if (allowOrderFallback) { - if (!fiveHourWindow) { - fiveHourWindow = primaryWindow && primaryWindow !== weeklyWindow ? primaryWindow : null; - } - if (!weeklyWindow) { - weeklyWindow = secondaryWindow && secondaryWindow !== fiveHourWindow ? secondaryWindow : null; - } - } - - return { fiveHourWindow, weeklyWindow }; - }; - - const rateWindows = pickClassifiedWindows(rateLimit); - addWindow( - WINDOW_META.codeFiveHour.id, - t(WINDOW_META.codeFiveHour.labelKey), - WINDOW_META.codeFiveHour.labelKey, - undefined, - rateWindows.fiveHourWindow, - rawLimitReached, - rawAllowed - ); - addWindow( - WINDOW_META.codeWeekly.id, - t(WINDOW_META.codeWeekly.labelKey), - WINDOW_META.codeWeekly.labelKey, - undefined, - rateWindows.weeklyWindow, - rawLimitReached, - rawAllowed - ); - - const codeReviewWindows = pickClassifiedWindows(codeReviewLimit); - const codeReviewLimitReached = codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached; - const codeReviewAllowed = codeReviewLimit?.allowed; - addWindow( - WINDOW_META.codeReviewFiveHour.id, - t(WINDOW_META.codeReviewFiveHour.labelKey), - WINDOW_META.codeReviewFiveHour.labelKey, - undefined, - codeReviewWindows.fiveHourWindow, - codeReviewLimitReached, - codeReviewAllowed - ); - addWindow( - WINDOW_META.codeReviewWeekly.id, - t(WINDOW_META.codeReviewWeekly.labelKey), - WINDOW_META.codeReviewWeekly.labelKey, - undefined, - codeReviewWindows.weeklyWindow, - codeReviewLimitReached, - codeReviewAllowed - ); - - const normalizeWindowId = (raw: string) => - raw - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - - if (Array.isArray(additionalRateLimits)) { - additionalRateLimits.forEach((limitItem, index) => { - const rateInfo = limitItem?.rate_limit ?? limitItem?.rateLimit ?? null; - if (!rateInfo) return; - - const limitName = - normalizeStringValue(limitItem?.limit_name ?? limitItem?.limitName) ?? - normalizeStringValue(limitItem?.metered_feature ?? limitItem?.meteredFeature) ?? - `additional-${index + 1}`; - - const idPrefix = normalizeWindowId(limitName) || `additional-${index + 1}`; - const additionalPrimaryWindow = rateInfo.primary_window ?? rateInfo.primaryWindow ?? null; - const additionalSecondaryWindow = rateInfo.secondary_window ?? rateInfo.secondaryWindow ?? null; - const additionalLimitReached = rateInfo.limit_reached ?? rateInfo.limitReached; - const additionalAllowed = rateInfo.allowed; - - addWindow( - `${idPrefix}-five-hour-${index}`, - t('codex_quota.additional_primary_window', { name: limitName }), - 'codex_quota.additional_primary_window', - { name: limitName }, - additionalPrimaryWindow, - additionalLimitReached, - additionalAllowed - ); - addWindow( - `${idPrefix}-weekly-${index}`, - t('codex_quota.additional_secondary_window', { name: limitName }), - 'codex_quota.additional_secondary_window', - { name: limitName }, - additionalSecondaryWindow, - additionalLimitReached, - additionalAllowed - ); - }); - } - - return windows; -}; +const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => + buildCodexQuotaWindowInfos(payload).map((window) => ({ + id: window.id, + label: t(window.labelKey, window.labelParams), + labelKey: window.labelKey, + labelParams: window.labelParams, + usedPercent: window.usedPercent, + resetLabel: window.resetLabel, + })); const fetchCodexQuota = async ( file: AuthFileItem, @@ -413,26 +257,12 @@ const fetchCodexQuota = async ( const planTypeFromFile = resolveCodexPlanType(file); const accountId = resolveCodexChatgptAccountId(file); - - const requestHeader: Record = { - ...CODEX_REQUEST_HEADERS, - }; - if (accountId) { - requestHeader['Chatgpt-Account-Id'] = accountId; - } - - const result = await apiCallApi.request({ - authIndex, - method: 'GET', - url: CODEX_USAGE_URL, - header: requestHeader, - }); + const { result, payload } = await requestCodexUsageRaw({ authIndex, accountId }); if (result.statusCode < 200 || result.statusCode >= 300) { throw createStatusError(getApiCallErrorMessage(result), result.statusCode); } - const payload = parseCodexUsagePayload(result.body ?? result.bodyText); if (!payload) { throw new Error(t('codex_quota.empty_windows')); } @@ -459,8 +289,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(); @@ -468,14 +297,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; }; @@ -484,14 +310,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) { @@ -750,10 +574,8 @@ const getCodexPlanLabel = (planType: string | null | undefined, t: TFunction): s return planType || normalized; }; -const getCodexEffectivePlanType = ( - file: AuthFileItem, - quota?: CodexQuotaState -): string | null => resolveCodexPlanType(file) ?? quota?.planType ?? null; +const getCodexEffectivePlanType = (file: AuthFileItem, quota?: CodexQuotaState): string | null => + resolveCodexPlanType(file) ?? quota?.planType ?? null; const getCodexPlanSortRank = (file: AuthFileItem, quota?: CodexQuotaState): number | null => { const normalized = normalizePlanType(getCodexEffectivePlanType(file, quota)); @@ -888,7 +710,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); } @@ -1002,8 +828,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'; @@ -1017,7 +847,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) { @@ -1248,7 +1082,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, @@ -1276,10 +1116,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) { @@ -1330,7 +1167,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( @@ -1344,12 +1181,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, { diff --git a/src/features/monitoring/codexInspection.ts b/src/features/monitoring/codexInspection.ts index 7405daea1..6c200655b 100644 --- a/src/features/monitoring/codexInspection.ts +++ b/src/features/monitoring/codexInspection.ts @@ -1,13 +1,15 @@ import type { AxiosRequestConfig } from 'axios'; import { authFilesApi } from '@/services/api/authFiles'; -import { apiCallApi, getApiCallErrorMessage } from '@/services/api/apiCall'; -import type { AuthFileItem, Config, CodexRateLimitInfo, CodexUsageWindow } from '@/types'; +import { getApiCallErrorMessage } from '@/services/api/apiCall'; +import { requestCodexUsageRaw } from '@/services/api/codexQuota'; +import type { AuthFileItem, Config, CodexRateLimitInfo } from '@/types'; import { - CODEX_REQUEST_HEADERS, - CODEX_USAGE_URL, + classifyCodexRateLimitWindows, + deriveCodexRateLimitUsedPercent, isDisabledAuthFile, + isCodexRateLimitReached, + getCodexQuotaWindowUsedPercent, normalizeNumberValue, - parseCodexUsagePayload, resolveAuthProvider, resolveCodexChatgptAccountId, } from '@/utils/quota'; @@ -164,8 +166,6 @@ export interface CodexInspectionSession { } const QUOTA_BODY_PATTERNS = ['quota exhausted', 'limit reached', 'payment_required']; -const FIVE_HOUR_WINDOW_SECONDS = 18000; -const WEEK_WINDOW_SECONDS = 604800; export class CodexInspectionStoppedError extends Error { constructor(message: string = '巡检已停止') { @@ -297,21 +297,35 @@ const normalizeConfigurableSettings = ( const sampleSizeValue = normalizeNumberValue(merged.sampleSize); return { - targetType: readString(merged.targetType).toLowerCase() || DEFAULT_CODEX_INSPECTION_SETTINGS.targetType, - workers: clampPositiveInteger(normalizeNumberValue(merged.workers) ?? undefined, DEFAULT_CODEX_INSPECTION_SETTINGS.workers), + targetType: + readString(merged.targetType).toLowerCase() || DEFAULT_CODEX_INSPECTION_SETTINGS.targetType, + workers: clampPositiveInteger( + normalizeNumberValue(merged.workers) ?? undefined, + DEFAULT_CODEX_INSPECTION_SETTINGS.workers + ), deleteWorkers: clampPositiveInteger( normalizeNumberValue(merged.deleteWorkers) ?? undefined, - clampPositiveInteger(normalizeNumberValue(merged.workers) ?? undefined, DEFAULT_CODEX_INSPECTION_SETTINGS.workers) + clampPositiveInteger( + normalizeNumberValue(merged.workers) ?? undefined, + DEFAULT_CODEX_INSPECTION_SETTINGS.workers + ) + ), + timeout: clampPositiveInteger( + normalizeNumberValue(merged.timeout) ?? undefined, + DEFAULT_CODEX_INSPECTION_SETTINGS.timeout ), - timeout: clampPositiveInteger(normalizeNumberValue(merged.timeout) ?? undefined, DEFAULT_CODEX_INSPECTION_SETTINGS.timeout), retries: - retriesValue === null ? DEFAULT_CODEX_INSPECTION_SETTINGS.retries : Math.max(0, Math.floor(retriesValue)), + retriesValue === null + ? DEFAULT_CODEX_INSPECTION_SETTINGS.retries + : Math.max(0, Math.floor(retriesValue)), userAgent: readString(merged.userAgent) || DEFAULT_CODEX_INSPECTION_SETTINGS.userAgent, usedPercentThreshold: Number.isFinite(threshold) ? Math.max(0, Math.min(100, threshold)) : DEFAULT_CODEX_INSPECTION_SETTINGS.usedPercentThreshold, sampleSize: - sampleSizeValue === null ? DEFAULT_CODEX_INSPECTION_SETTINGS.sampleSize : Math.max(0, Math.floor(sampleSizeValue)), + sampleSizeValue === null + ? DEFAULT_CODEX_INSPECTION_SETTINGS.sampleSize + : Math.max(0, Math.floor(sampleSizeValue)), autoExecuteActions: readBoolean( merged.autoExecuteActions, DEFAULT_CODEX_INSPECTION_SETTINGS.autoExecuteActions @@ -371,7 +385,7 @@ export const clearCodexInspectionConfigurableSettings = () => { } }; -const pickSample = (items: T[], sampleSize: number): T[] => { +const pickSample = (items: T[], sampleSize: number): T[] => { if (sampleSize <= 0 || sampleSize >= items.length) return [...items]; const shuffled = [...items]; @@ -382,7 +396,7 @@ const pickSample = (items: T[], sampleSize: number): T[] => { return shuffled.slice(0, sampleSize); }; -const withRetry = async (retries: number, task: () => Promise): Promise => { +const withRetry = async (retries: number, task: () => Promise): Promise => { let lastError: unknown; for (let attempt = 0; attempt <= retries; attempt += 1) { @@ -422,65 +436,6 @@ const runConcurrently = async ( return results; }; -const getWindowUsedPercent = (window?: CodexUsageWindow | null) => - normalizeNumberValue(window?.used_percent ?? window?.usedPercent); - -const getWindowSeconds = (window?: CodexUsageWindow | null) => - normalizeNumberValue(window?.limit_window_seconds ?? window?.limitWindowSeconds); - -const getLimitWindows = (rateLimit?: CodexRateLimitInfo | null) => [ - rateLimit?.primary_window ?? rateLimit?.primaryWindow ?? null, - rateLimit?.secondary_window ?? rateLimit?.secondaryWindow ?? null, -]; - -const pickClassifiedWindows = ( - rateLimit?: CodexRateLimitInfo | null -): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => { - const primaryWindow = rateLimit?.primary_window ?? rateLimit?.primaryWindow ?? null; - const secondaryWindow = rateLimit?.secondary_window ?? rateLimit?.secondaryWindow ?? null; - const rawWindows = [primaryWindow, secondaryWindow]; - - let fiveHourWindow: CodexUsageWindow | null = null; - let weeklyWindow: CodexUsageWindow | null = null; - - rawWindows.forEach((window) => { - if (!window) return; - const seconds = getWindowSeconds(window); - if (seconds === FIVE_HOUR_WINDOW_SECONDS && !fiveHourWindow) { - fiveHourWindow = window; - } else if (seconds === WEEK_WINDOW_SECONDS && !weeklyWindow) { - weeklyWindow = window; - } - }); - - if (!fiveHourWindow) { - fiveHourWindow = primaryWindow && primaryWindow !== weeklyWindow ? primaryWindow : null; - } - if (!weeklyWindow) { - weeklyWindow = secondaryWindow && secondaryWindow !== fiveHourWindow ? secondaryWindow : null; - } - - return { fiveHourWindow, weeklyWindow }; -}; - -const deriveUsedPercent = (rateLimit?: CodexRateLimitInfo | null): number | null => { - const values = getLimitWindows(rateLimit) - .map((window) => getWindowUsedPercent(window)) - .filter((value): value is number => value !== null); - if (!values.length) return null; - return Math.max(...values); -}; - -const isRateLimitReached = (rateLimit?: CodexRateLimitInfo | null) => { - if (!rateLimit) return false; - if (rateLimit.allowed === false) return true; - if (rateLimit.limit_reached === true || rateLimit.limitReached === true) return true; - return getLimitWindows(rateLimit).some((window) => { - const value = getWindowUsedPercent(window); - return value !== null && value >= 100; - }); -}; - type CodexInspectionDecision = Pick< CodexInspectionResultItem, 'action' | 'actionReason' | 'usedPercent' | 'isQuota' @@ -542,11 +497,11 @@ const resolveWindowAwareProbeAction = ( ): CodexInspectionDecision | null => { if (!rateLimit) return null; - const { fiveHourWindow, weeklyWindow } = pickClassifiedWindows(rateLimit); - const weeklyUsedPercent = getWindowUsedPercent(weeklyWindow); + const { fiveHourWindow, weeklyWindow } = classifyCodexRateLimitWindows(rateLimit); + const weeklyUsedPercent = getCodexQuotaWindowUsedPercent(weeklyWindow); if (!weeklyWindow || weeklyUsedPercent === null) return null; - const fiveHourUsedPercent = getWindowUsedPercent(fiveHourWindow); + const fiveHourUsedPercent = getCodexQuotaWindowUsedPercent(fiveHourWindow); const weeklyOverThreshold = weeklyUsedPercent >= threshold; const fiveHourOverThreshold = fiveHourUsedPercent !== null && fiveHourUsedPercent >= threshold; @@ -612,7 +567,12 @@ const resolveProbeAction = ( isQuota: boolean, threshold: number ): CodexInspectionDecision => { - const windowAwareDecision = resolveWindowAwareProbeAction(account, statusCode, rateLimit, threshold); + const windowAwareDecision = resolveWindowAwareProbeAction( + account, + statusCode, + rateLimit, + threshold + ); if (windowAwareDecision) return windowAwareDecision; return resolveLegacyProbeAction(account, statusCode, usedPercent, isQuota, threshold); }; @@ -635,24 +595,18 @@ const inspectSingleAccount = async ( }; } - const requestConfig: AxiosRequestConfig = settings.timeout > 0 ? { timeout: settings.timeout } : {}; - const headers = { - ...CODEX_REQUEST_HEADERS, - 'User-Agent': settings.userAgent, - ...(account.accountId ? { 'Chatgpt-Account-Id': account.accountId } : {}), - }; + const authIndex = account.authIndex; + const requestConfig: AxiosRequestConfig = + settings.timeout > 0 ? { timeout: settings.timeout } : {}; try { - const result = await withRetry(settings.retries, () => - apiCallApi.request( - { - authIndex: account.authIndex ?? undefined, - method: 'GET', - url: CODEX_USAGE_URL, - header: headers, - }, - requestConfig - ) + const { result, payload } = await withRetry(settings.retries, () => + requestCodexUsageRaw({ + authIndex, + accountId: account.accountId, + userAgent: settings.userAgent, + requestConfig, + }) ); if (!result.hasStatusCode) { @@ -668,14 +622,13 @@ const inspectSingleAccount = async ( }; } - const payload = parseCodexUsagePayload(result.body ?? result.bodyText); const rateLimit = payload?.rate_limit ?? payload?.rateLimit ?? null; - const usedPercent = deriveUsedPercent(rateLimit); + const usedPercent = deriveCodexRateLimitUsedPercent(rateLimit); const bodyText = result.bodyText.toLowerCase(); const isQuota = result.statusCode === 402 || QUOTA_BODY_PATTERNS.some((pattern) => bodyText.includes(pattern)) || - isRateLimitReached(rateLimit) || + isCodexRateLimitReached(rateLimit) || (usedPercent !== null && usedPercent >= settings.usedPercentThreshold); const decision = resolveProbeAction( account, @@ -694,7 +647,8 @@ const inspectSingleAccount = async ( : decision.action === 'enable' ? 'success' : 'info'; - const percentText = decision.usedPercent === null ? '--' : `${decision.usedPercent.toFixed(1)}%`; + const percentText = + decision.usedPercent === null ? '--' : `${decision.usedPercent.toFixed(1)}%`; onLog?.( successLevel, `${account.displayAccount} -> ${decision.action} (HTTP ${result.statusCode} · 已用 ${percentText})` @@ -871,8 +825,23 @@ export const createCodexInspectionSession = ({ const emitProgress = () => { const baseTime = startedAt || Date.now(); - const summary = buildProgressSummary(files, probeSet, sampledAccounts, Array.from(resultMap.values())); - onProgress?.(createProgressSnapshot(sampledAccounts.length, resultMap.size, inFlight, status, baseTime, Date.now(), summary)); + const summary = buildProgressSummary( + files, + probeSet, + sampledAccounts, + Array.from(resultMap.values()) + ); + onProgress?.( + createProgressSnapshot( + sampledAccounts.length, + resultMap.size, + inFlight, + status, + baseTime, + Date.now(), + summary + ) + ); }; const buildRunResult = (finishedTime: number): CodexInspectionRunResult => { @@ -934,7 +903,11 @@ export const createCodexInspectionSession = ({ return; } - while (status === 'running' && inFlight < resolvedSettings.workers && cursor < sampledAccounts.length) { + while ( + status === 'running' && + inFlight < resolvedSettings.workers && + cursor < sampledAccounts.length + ) { const account = sampledAccounts[cursor]; cursor += 1; inFlight += 1; @@ -1121,10 +1094,14 @@ const dedupeExecutionItems = (items: CodexInspectionResultItem[]) => { map.set(item.fileName, item); } }); - return Array.from(map.values()).sort((left, right) => left.fileName.localeCompare(right.fileName)); + return Array.from(map.values()).sort((left, right) => + left.fileName.localeCompare(right.fileName) + ); }; -const executeDelete = async (item: CodexInspectionResultItem): Promise => { +const executeDelete = async ( + item: CodexInspectionResultItem +): Promise => { try { const result = await authFilesApi.deleteFileByName(item.fileName); const failed = result.failed[0]; @@ -1193,7 +1170,11 @@ export const executeCodexInspectionActions = async ({ if (deleteItems.length > 0) { onLog?.('info', `开始删除 ${deleteItems.length} 个账号`); - const deleteOutcomes = await runConcurrently(deleteItems, settings.deleteWorkers, executeDelete); + const deleteOutcomes = await runConcurrently( + deleteItems, + settings.deleteWorkers, + executeDelete + ); deleteOutcomes.forEach((outcome) => { onLog?.( outcome.success ? 'success' : 'error', @@ -1255,8 +1236,9 @@ export const buildExecutionFailureMessage = (outcome: CodexInspectionExecutionOu export const isSuggestedAction = (item: CodexInspectionResultItem) => item.action !== 'keep'; -export const isCodexInspectionStoppedError = (error: unknown): error is CodexInspectionStoppedError => - error instanceof CodexInspectionStoppedError; +export const isCodexInspectionStoppedError = ( + error: unknown +): error is CodexInspectionStoppedError => error instanceof CodexInspectionStoppedError; export const applyCodexInspectionExecutionResult = ( previousResult: CodexInspectionRunResult, @@ -1290,7 +1272,12 @@ export const applyCodexInspectionExecutionResult = ( return { ...baseItem, - disabled: outcome.action === 'disable' ? true : outcome.action === 'enable' ? false : baseItem.disabled, + disabled: + outcome.action === 'disable' + ? true + : outcome.action === 'enable' + ? false + : baseItem.disabled, action: 'keep', actionReason: '无需处理', error: '', @@ -1330,4 +1317,11 @@ export const buildSuggestedActionCountLabel = (summary: CodexInspectionSummary) summary.deleteCount + summary.disableCount + summary.enableCount; export const getProbeFailureMessage = (result: CodexInspectionResultItem) => - result.error || getApiCallErrorMessage({ statusCode: result.statusCode || 0, hasStatusCode: true, header: {}, bodyText: '', body: null }); + result.error || + getApiCallErrorMessage({ + statusCode: result.statusCode || 0, + hasStatusCode: true, + header: {}, + bodyText: '', + body: null, + }); diff --git a/src/services/api/codexQuota.test.ts b/src/services/api/codexQuota.test.ts new file mode 100644 index 000000000..3f0190638 --- /dev/null +++ b/src/services/api/codexQuota.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { buildCodexUsageRequestHeaders } from './codexQuota'; + +describe('buildCodexUsageRequestHeaders', () => { + it('does not include Chatgpt-Account-Id when account id is missing', () => { + const headers = buildCodexUsageRequestHeaders(null); + + expect(headers).not.toHaveProperty('Chatgpt-Account-Id'); + expect(headers.Authorization).toBe('Bearer $TOKEN$'); + }); + + it('includes trimmed account id when available', () => { + const headers = buildCodexUsageRequestHeaders(' account-123 '); + + expect(headers['Chatgpt-Account-Id']).toBe('account-123'); + }); + + it('allows Codex inspection to override User-Agent', () => { + const headers = buildCodexUsageRequestHeaders('account-123', { + userAgent: 'codex-test-agent', + }); + + expect(headers['User-Agent']).toBe('codex-test-agent'); + }); +}); diff --git a/src/services/api/codexQuota.ts b/src/services/api/codexQuota.ts new file mode 100644 index 000000000..823a189e7 --- /dev/null +++ b/src/services/api/codexQuota.ts @@ -0,0 +1,73 @@ +import type { AxiosRequestConfig } from 'axios'; +import type { CodexUsagePayload } from '@/types'; +import { CODEX_REQUEST_HEADERS, CODEX_USAGE_URL, parseCodexUsagePayload } from '@/utils/quota'; +import { apiCallApi, getApiCallErrorMessage, type ApiCallResult } from './apiCall'; + +export type CodexUsageRequestParams = { + authIndex: string; + accountId?: string | null; + userAgent?: string; + requestConfig?: AxiosRequestConfig; +}; + +export type CodexUsageRawResult = { + result: ApiCallResult; + payload: CodexUsagePayload | null; +}; + +export const buildCodexUsageRequestHeaders = ( + accountId?: string | null, + options: { userAgent?: string } = {} +): Record => { + const headers: Record = { + ...CODEX_REQUEST_HEADERS, + }; + + const trimmedAccountId = String(accountId ?? '').trim(); + if (trimmedAccountId) { + headers['Chatgpt-Account-Id'] = trimmedAccountId; + } + + const userAgent = String(options.userAgent ?? '').trim(); + if (userAgent) { + headers['User-Agent'] = userAgent; + } + + return headers; +}; + +export const requestCodexUsageRaw = async ({ + authIndex, + accountId, + userAgent, + requestConfig, +}: CodexUsageRequestParams): Promise => { + const result = await apiCallApi.request( + { + authIndex, + method: 'GET', + url: CODEX_USAGE_URL, + header: buildCodexUsageRequestHeaders(accountId, { userAgent }), + }, + requestConfig + ); + + return { + result, + payload: parseCodexUsagePayload(result.body ?? result.bodyText), + }; +}; + +export const requestCodexUsagePayload = async ( + params: CodexUsageRequestParams, + options: { emptyMessage?: string } = {} +): Promise => { + const { result, payload } = await requestCodexUsageRaw(params); + if (result.statusCode < 200 || result.statusCode >= 300) { + throw new Error(getApiCallErrorMessage(result)); + } + if (!payload) { + throw new Error(options.emptyMessage || 'No Codex quota data available'); + } + return payload; +}; diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 69acada9f..52f34d63e 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -14,3 +14,4 @@ export * from './version'; export * from './models'; export * from './transformers'; export * from './vertex'; +export * from './codexQuota'; diff --git a/src/utils/quota/codexQuota.test.ts b/src/utils/quota/codexQuota.test.ts new file mode 100644 index 000000000..94775dd8c --- /dev/null +++ b/src/utils/quota/codexQuota.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { + classifyCodexRateLimitWindows, + deriveCodexRateLimitUsedPercent, + isCodexRateLimitReached, + buildCodexQuotaWindowInfos, +} from './codexQuota'; + +describe('buildCodexQuotaWindowInfos', () => { + it('classifies Codex primary and weekly windows by duration', () => { + const windows = buildCodexQuotaWindowInfos({ + rate_limit: { + primary_window: { + used_percent: 10, + limit_window_seconds: 604_800, + reset_after_seconds: 60, + }, + secondary_window: { + used_percent: 30, + limit_window_seconds: 18_000, + reset_after_seconds: 120, + }, + }, + }); + + expect(windows.map((window) => [window.id, window.usedPercent])).toEqual([ + ['five-hour', 30], + ['weekly', 10], + ]); + }); + + it('marks reached windows as fully used when usage percent is absent', () => { + const windows = buildCodexQuotaWindowInfos({ + rate_limit: { + limit_reached: true, + primary_window: { + limit_window_seconds: 18_000, + reset_after_seconds: 300, + }, + }, + }); + + expect(windows[0]).toMatchObject({ + id: 'five-hour', + usedPercent: 100, + }); + }); + + it('normalizes additional rate limit labels into stable ids and params', () => { + const windows = buildCodexQuotaWindowInfos({ + additional_rate_limits: [ + { + limit_name: 'Code Review Premium', + rate_limit: { + primary_window: { + used_percent: 45, + limit_window_seconds: 18_000, + reset_after_seconds: 600, + }, + secondary_window: { + used_percent: 55, + limit_window_seconds: 604_800, + reset_after_seconds: 1_200, + }, + }, + }, + ], + }); + + expect(windows).toMatchObject([ + { + id: 'code-review-premium-five-hour-0', + labelKey: 'codex_quota.additional_primary_window', + labelParams: { name: 'Code Review Premium' }, + usedPercent: 45, + }, + { + id: 'code-review-premium-weekly-0', + labelKey: 'codex_quota.additional_secondary_window', + labelParams: { name: 'Code Review Premium' }, + usedPercent: 55, + }, + ]); + }); + + it('shares rate-limit helpers used by Codex inspection', () => { + const rateLimit = { + allowed: true, + primary_window: { + used_percent: 65, + limit_window_seconds: 604_800, + }, + secondary_window: { + used_percent: 100, + limit_window_seconds: 18_000, + }, + }; + + const classified = classifyCodexRateLimitWindows(rateLimit); + + expect(classified.fiveHourWindow?.used_percent).toBe(100); + expect(classified.weeklyWindow?.used_percent).toBe(65); + expect(deriveCodexRateLimitUsedPercent(rateLimit)).toBe(100); + expect(isCodexRateLimitReached(rateLimit)).toBe(true); + }); +}); diff --git a/src/utils/quota/codexQuota.ts b/src/utils/quota/codexQuota.ts new file mode 100644 index 000000000..50a409a47 --- /dev/null +++ b/src/utils/quota/codexQuota.ts @@ -0,0 +1,233 @@ +import type { + CodexAdditionalRateLimit, + CodexRateLimitInfo, + CodexUsagePayload, + CodexUsageWindow, +} from '@/types'; +import { formatCodexResetLabel } from './formatters'; +import { normalizeNumberValue, normalizeStringValue } from './parsers'; + +const FIVE_HOUR_SECONDS = 18_000; +const WEEK_SECONDS = 604_800; + +type CodexQuotaWindowMeta = { + id: string; + labelKey: string; +}; + +const CODEX_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', + }, +} as const satisfies Record; + +export type CodexQuotaWindowInfo = { + id: string; + labelKey: string; + labelParams?: Record; + usedPercent: number | null; + resetLabel: string; + limitWindowSeconds: number | null; +}; + +const getWindowSeconds = (window?: CodexUsageWindow | null): number | null => { + if (!window) return null; + return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds); +}; + +export const getCodexQuotaWindowUsedPercent = (window?: CodexUsageWindow | null): number | null => + normalizeNumberValue(window?.used_percent ?? window?.usedPercent); + +const normalizeWindowId = (raw: string) => + raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + +const pickClassifiedWindows = ( + limitInfo?: CodexRateLimitInfo | null, + options?: { allowOrderFallback?: boolean } +): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => { + const allowOrderFallback = options?.allowOrderFallback ?? true; + const primaryWindow = limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null; + const secondaryWindow = limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null; + const rawWindows = [primaryWindow, secondaryWindow]; + + let fiveHourWindow: CodexUsageWindow | null = null; + let weeklyWindow: CodexUsageWindow | null = null; + + for (const window of rawWindows) { + if (!window) continue; + const seconds = getWindowSeconds(window); + if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) { + fiveHourWindow = window; + } else if (seconds === WEEK_SECONDS && !weeklyWindow) { + weeklyWindow = window; + } + } + + if (allowOrderFallback) { + if (!fiveHourWindow) { + fiveHourWindow = primaryWindow && primaryWindow !== weeklyWindow ? primaryWindow : null; + } + if (!weeklyWindow) { + weeklyWindow = secondaryWindow && secondaryWindow !== fiveHourWindow ? secondaryWindow : null; + } + } + + return { fiveHourWindow, weeklyWindow }; +}; + +export const classifyCodexRateLimitWindows = pickClassifiedWindows; + +export const getCodexRateLimitWindows = (rateLimit?: CodexRateLimitInfo | null) => [ + rateLimit?.primary_window ?? rateLimit?.primaryWindow ?? null, + rateLimit?.secondary_window ?? rateLimit?.secondaryWindow ?? null, +]; + +export const deriveCodexRateLimitUsedPercent = ( + rateLimit?: CodexRateLimitInfo | null +): number | null => { + const values = getCodexRateLimitWindows(rateLimit) + .map((window) => getCodexQuotaWindowUsedPercent(window)) + .filter((value): value is number => value !== null); + if (!values.length) return null; + return Math.max(...values); +}; + +export const isCodexRateLimitReached = (rateLimit?: CodexRateLimitInfo | null): boolean => { + if (!rateLimit) return false; + if (rateLimit.allowed === false) return true; + if (rateLimit.limit_reached === true || rateLimit.limitReached === true) return true; + return getCodexRateLimitWindows(rateLimit).some((window) => { + const value = getCodexQuotaWindowUsedPercent(window); + return value !== null && value >= 100; + }); +}; + +const addCodexWindowInfo = ( + windows: CodexQuotaWindowInfo[], + id: string, + labelKey: string, + labelParams: Record | undefined, + window?: CodexUsageWindow | null, + limitReached?: boolean, + allowed?: boolean +) => { + if (!window) return; + + const resetLabel = formatCodexResetLabel(window); + const usedPercentRaw = getCodexQuotaWindowUsedPercent(window); + const isLimitReached = Boolean(limitReached) || allowed === false; + const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null); + + windows.push({ + id, + labelKey, + labelParams, + usedPercent, + resetLabel, + limitWindowSeconds: getWindowSeconds(window), + }); +}; + +const addCodexRateLimitWindows = ( + windows: CodexQuotaWindowInfo[], + limitInfo: CodexRateLimitInfo | null | undefined, + fiveHourMeta: CodexQuotaWindowMeta, + weeklyMeta: CodexQuotaWindowMeta +) => { + const limitReached = limitInfo?.limit_reached ?? limitInfo?.limitReached; + const allowed = limitInfo?.allowed; + const classified = pickClassifiedWindows(limitInfo); + + addCodexWindowInfo( + windows, + fiveHourMeta.id, + fiveHourMeta.labelKey, + undefined, + classified.fiveHourWindow, + limitReached, + allowed + ); + addCodexWindowInfo( + windows, + weeklyMeta.id, + weeklyMeta.labelKey, + undefined, + classified.weeklyWindow, + limitReached, + allowed + ); +}; + +const addAdditionalRateLimitWindows = ( + windows: CodexQuotaWindowInfo[], + additionalRateLimits: CodexAdditionalRateLimit[] | null | undefined +) => { + if (!Array.isArray(additionalRateLimits)) return; + + additionalRateLimits.forEach((limitItem, index) => { + const rateInfo = limitItem?.rate_limit ?? limitItem?.rateLimit ?? null; + if (!rateInfo) return; + + const limitName = + normalizeStringValue(limitItem?.limit_name ?? limitItem?.limitName) ?? + normalizeStringValue(limitItem?.metered_feature ?? limitItem?.meteredFeature) ?? + `additional-${index + 1}`; + const idPrefix = normalizeWindowId(limitName) || `additional-${index + 1}`; + const limitReached = rateInfo.limit_reached ?? rateInfo.limitReached; + const allowed = rateInfo.allowed; + + addCodexWindowInfo( + windows, + `${idPrefix}-five-hour-${index}`, + 'codex_quota.additional_primary_window', + { name: limitName }, + rateInfo.primary_window ?? rateInfo.primaryWindow ?? null, + limitReached, + allowed + ); + addCodexWindowInfo( + windows, + `${idPrefix}-weekly-${index}`, + 'codex_quota.additional_secondary_window', + { name: limitName }, + rateInfo.secondary_window ?? rateInfo.secondaryWindow ?? null, + limitReached, + allowed + ); + }); +}; + +export const buildCodexQuotaWindowInfos = (payload: CodexUsagePayload): CodexQuotaWindowInfo[] => { + const windows: CodexQuotaWindowInfo[] = []; + const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined; + const codeReviewLimit = + payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined; + const additionalRateLimits = payload.additional_rate_limits ?? payload.additionalRateLimits; + + addCodexRateLimitWindows( + windows, + rateLimit, + CODEX_WINDOW_META.codeFiveHour, + CODEX_WINDOW_META.codeWeekly + ); + addCodexRateLimitWindows( + windows, + codeReviewLimit, + CODEX_WINDOW_META.codeReviewFiveHour, + CODEX_WINDOW_META.codeReviewWeekly + ); + addAdditionalRateLimitWindows(windows, additionalRateLimits); + + return windows; +}; diff --git a/src/utils/quota/index.ts b/src/utils/quota/index.ts index 6a10fa3f8..84b3f7313 100644 --- a/src/utils/quota/index.ts +++ b/src/utils/quota/index.ts @@ -8,3 +8,4 @@ export * from './resolvers'; export * from './formatters'; export * from './validators'; export * from './builders'; +export * from './codexQuota'; From d47559ffc8470c45af9864fa184d28893e1c592a Mon Sep 17 00:00:00 2001 From: seakee Date: Thu, 14 May 2026 10:50:10 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20fix(monitoring):=20allow=20c?= =?UTF-8?q?odex=20quota=20refresh=20without=20account=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the request monitoring hard failure when Codex credentials lack a ChatGPT account ID. Use the shared optional-header Codex quota request helper. Adds regression coverage for account-id-less Codex quota targets. --- .../accountOverviewQuotaTargets.test.ts | 45 +++- src/pages/MonitoringCenterPage.tsx | 204 +++--------------- 2 files changed, 68 insertions(+), 181 deletions(-) diff --git a/src/features/monitoring/accountOverviewQuotaTargets.test.ts b/src/features/monitoring/accountOverviewQuotaTargets.test.ts index 15f05730d..cdeabf598 100644 --- a/src/features/monitoring/accountOverviewQuotaTargets.test.ts +++ b/src/features/monitoring/accountOverviewQuotaTargets.test.ts @@ -3,9 +3,7 @@ import type { MonitoringAccountRow } from './hooks/useMonitoringData'; import type { MonitoringAccountAuthState } from './accountOverviewState'; import { buildMonitoringAccountQuotaTargetsByAccount } from './accountOverviewQuotaTargets'; -const createAccountRow = ( - overrides: Partial = {} -): MonitoringAccountRow => ({ +const createAccountRow = (overrides: Partial = {}): MonitoringAccountRow => ({ id: overrides.id ?? 'account@example.com', account: overrides.account ?? 'account@example.com', displayAccount: overrides.displayAccount ?? overrides.account ?? 'account@example.com', @@ -80,4 +78,45 @@ describe('accountOverviewQuotaTargets', () => { { authIndex: '2', fileName: 'beta.json', authLabel: 'Beta' }, ]); }); + + it('keeps Codex quota targets when the account id is unavailable', () => { + const authStateByRowId = new Map([ + [ + 'account@example.com', + { + files: [ + { + name: 'codex-without-account.json', + type: 'codex', + authIndex: '1', + label: 'Codex', + account: 'account@example.com', + }, + ], + toggleableFileNames: ['codex-without-account.json'], + enabledState: 'enabled', + }, + ], + ]); + + const result = buildMonitoringAccountQuotaTargetsByAccount( + [ + createAccountRow({ + id: 'account@example.com', + account: 'account@example.com', + authIndices: ['1'], + authLabels: ['Codex'], + }), + ], + authStateByRowId + ); + + expect(result.get('account@example.com')).toMatchObject([ + { + authIndex: '1', + fileName: 'codex-without-account.json', + accountId: null, + }, + ]); + }); }); diff --git a/src/pages/MonitoringCenterPage.tsx b/src/pages/MonitoringCenterPage.tsx index 2b860a784..0e40458e4 100644 --- a/src/pages/MonitoringCenterPage.tsx +++ b/src/pages/MonitoringCenterPage.tsx @@ -85,24 +85,12 @@ import { useUsageData } from '@/features/monitoring/hooks/useUsageData'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { useInterval } from '@/hooks/useInterval'; import { useRequestMonitoringAvailability } from '@/hooks/useRequestMonitoringAvailability'; -import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api'; +import { authFilesApi, requestCodexUsagePayload } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; -import type { - AuthFileItem, - CodexRateLimitInfo, - CodexUsagePayload, - CodexUsageWindow, -} from '@/types'; +import type { AuthFileItem, CodexUsagePayload } from '@/types'; import { formatFileSize, maskSensitiveText } from '@/utils/format'; import type { StatusBarData, StatusBlockDetail } from '@/utils/recentRequests'; -import { - CODEX_REQUEST_HEADERS, - CODEX_USAGE_URL, - formatCodexResetLabel, - normalizeNumberValue, - normalizePlanType, - parseCodexUsagePayload, -} from '@/utils/quota'; +import { buildCodexQuotaWindowInfos, normalizePlanType } from '@/utils/quota'; import { formatCompactNumber, formatDurationMs, @@ -318,8 +306,6 @@ const buildRealtimeMetaText = (row: MonitoringEventRow) => { return maskSensitiveText(text || '-'); }; -const FIVE_HOUR_SECONDS = 18000; -const WEEK_SECONDS = 604800; const PREMIUM_CODEX_PLAN_TYPES = new Set(['pro', 'prolite', 'pro-lite', 'pro_lite']); const getCodexPlanLabel = (planType: string | null | undefined, t: TFunction): string | null => { @@ -416,38 +402,19 @@ const buildAccountSummaryMetrics = ( }, ]; -const buildAccountQuotaWindows = ( - payload: CodexUsagePayload, - t: TFunction -): AccountQuotaWindow[] => { - const windows: AccountQuotaWindow[] = []; - const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined; - const codeReviewLimit = - payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined; - const additionalRateLimits = payload.additional_rate_limits ?? payload.additionalRateLimits ?? []; - - const addWindow = ( - id: string, - label: string, - window?: CodexUsageWindow | null, - limitReached?: boolean, - allowed?: boolean - ) => { - if (!window) return; - - const resetLabel = formatCodexResetLabel(window); - const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent); - const isLimitReached = Boolean(limitReached) || allowed === false; - const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null); - const clampedUsed = usedPercent === null ? null : Math.max(0, Math.min(100, usedPercent)); +const buildAccountQuotaWindows = (payload: CodexUsagePayload, t: TFunction): AccountQuotaWindow[] => + buildCodexQuotaWindowInfos(payload).map((window) => { + const clampedUsed = + window.usedPercent === null ? null : Math.max(0, Math.min(100, window.usedPercent)); const remainingPercent = clampedUsed === null ? null : Math.max(0, 100 - clampedUsed); - - const totalSeconds = normalizeNumberValue( - window.limit_window_seconds ?? window.limitWindowSeconds - ); let usageLabel: string | null = null; - if (totalSeconds !== null && totalSeconds > 0 && clampedUsed !== null) { - const totalHours = totalSeconds / 3600; + + if ( + window.limitWindowSeconds !== null && + window.limitWindowSeconds > 0 && + clampedUsed !== null + ) { + const totalHours = window.limitWindowSeconds / 3600; const usedHours = (totalHours * clampedUsed) / 100; const formatHours = (value: number) => Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1); @@ -457,145 +424,26 @@ const buildAccountQuotaWindows = ( }); } - windows.push({ - id, - label, + return { + id: window.id, + label: t(window.labelKey, window.labelParams), remainingPercent, - resetLabel, + resetLabel: window.resetLabel, usageLabel, - }); - }; - - const getWindowSeconds = (window?: CodexUsageWindow | null): number | null => { - if (!window) return null; - return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds); - }; - - const pickClassifiedWindows = ( - limitInfo?: CodexRateLimitInfo | null - ): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => { - const primaryWindow = limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null; - const secondaryWindow = limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null; - const rawWindows = [primaryWindow, secondaryWindow]; - - let fiveHourWindow: CodexUsageWindow | null = null; - let weeklyWindow: CodexUsageWindow | null = null; - - rawWindows.forEach((window) => { - if (!window) return; - const seconds = getWindowSeconds(window); - if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) { - fiveHourWindow = window; - } else if (seconds === WEEK_SECONDS && !weeklyWindow) { - weeklyWindow = window; - } - }); - - if (!fiveHourWindow) { - fiveHourWindow = primaryWindow && primaryWindow !== weeklyWindow ? primaryWindow : null; - } - if (!weeklyWindow) { - weeklyWindow = secondaryWindow && secondaryWindow !== fiveHourWindow ? secondaryWindow : null; - } - - return { fiveHourWindow, weeklyWindow }; - }; - - const rateLimitReached = rateLimit?.limit_reached ?? rateLimit?.limitReached; - const rateAllowed = rateLimit?.allowed; - const rateWindows = pickClassifiedWindows(rateLimit); - addWindow( - 'five-hour', - t('codex_quota.primary_window'), - rateWindows.fiveHourWindow, - rateLimitReached, - rateAllowed - ); - addWindow( - 'weekly', - t('codex_quota.secondary_window'), - rateWindows.weeklyWindow, - rateLimitReached, - rateAllowed - ); - - const codeReviewLimitReached = codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached; - const codeReviewAllowed = codeReviewLimit?.allowed; - const codeReviewWindows = pickClassifiedWindows(codeReviewLimit); - addWindow( - 'code-review-five-hour', - t('codex_quota.code_review_primary_window'), - codeReviewWindows.fiveHourWindow, - codeReviewLimitReached, - codeReviewAllowed - ); - addWindow( - 'code-review-weekly', - t('codex_quota.code_review_secondary_window'), - codeReviewWindows.weeklyWindow, - codeReviewLimitReached, - codeReviewAllowed - ); - - if (Array.isArray(additionalRateLimits)) { - additionalRateLimits.forEach((limitItem, index) => { - const rateInfo = limitItem?.rate_limit ?? limitItem?.rateLimit ?? null; - if (!rateInfo) return; - - const limitName = - limitItem?.limit_name ?? - limitItem?.limitName ?? - limitItem?.metered_feature ?? - limitItem?.meteredFeature ?? - `additional-${index + 1}`; - const limitLabel = String(limitName).trim() || `additional-${index + 1}`; - - addWindow( - `${limitLabel}-primary-${index}`, - t('codex_quota.additional_primary_window', { name: limitLabel }), - rateInfo.primary_window ?? rateInfo.primaryWindow ?? null, - rateInfo.limit_reached ?? rateInfo.limitReached, - rateInfo.allowed - ); - addWindow( - `${limitLabel}-secondary-${index}`, - t('codex_quota.additional_secondary_window', { name: limitLabel }), - rateInfo.secondary_window ?? rateInfo.secondaryWindow ?? null, - rateInfo.limit_reached ?? rateInfo.limitReached, - rateInfo.allowed - ); - }); - } - - return windows; -}; + }; + }); const requestAccountQuota = async ( target: MonitoringAccountQuotaTarget, t: TFunction ): Promise => { - if (!target.accountId) { - throw new Error(t('codex_quota.missing_account_id')); - } - - const result = await apiCallApi.request({ - authIndex: target.authIndex, - method: 'GET', - url: CODEX_USAGE_URL, - header: { - ...CODEX_REQUEST_HEADERS, - 'Chatgpt-Account-Id': target.accountId, + const payload = await requestCodexUsagePayload( + { + authIndex: target.authIndex, + accountId: target.accountId, }, - }); - - if (result.statusCode < 200 || result.statusCode >= 300) { - throw new Error(getApiCallErrorMessage(result)); - } - - const payload = parseCodexUsagePayload(result.body ?? result.bodyText); - if (!payload) { - throw new Error(t('codex_quota.empty_windows')); - } + { emptyMessage: t('codex_quota.empty_windows') } + ); return { key: target.key,