diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts index d931d11f..f9145408 100644 --- a/src/components/quota/quotaConfigs.ts +++ b/src/components/quota/quotaConfigs.ts @@ -17,6 +17,7 @@ import type { ClaudeQuotaWindow, ClaudeUsagePayload, CodexRateLimitInfo, + CodexRateLimitResetCredit, CodexQuotaState, CodexUsageWindow, CodexQuotaWindow, @@ -41,6 +42,7 @@ import { CLAUDE_USAGE_URL, CLAUDE_REQUEST_HEADERS, CLAUDE_USAGE_WINDOW_KEYS, + CODEX_RATE_LIMIT_RESET_CREDITS_URL, CODEX_RATE_LIMIT_RESET_CREDITS_CONSUME_URL, CODEX_USAGE_URL, CODEX_REQUEST_HEADERS, @@ -51,6 +53,7 @@ import { normalizeNumberValue, normalizePlanType, normalizeStringValue, + normalizeCodexResetCreditsPayload, parseAntigravityPayload, parseClaudeUsagePayload, parseCodexUsagePayload, @@ -65,6 +68,7 @@ import { buildAntigravityQuotaGroups, buildKimiQuotaRows, createStatusError, + formatShanghaiDateTime, getStatusFromError, isAntigravityFile, isClaudeFile, @@ -88,8 +92,24 @@ type AntigravityQuotaData = { serverTimeOffsetMs: number | null; }; +type CodexResetCreditsData = { + availableCount: number | null; + credits: CodexRateLimitResetCredit[]; + error: string; +}; + +type CodexQuotaData = { + planType: string | null; + subscriptionActiveUntil: string | number | null; + rateLimitResetCreditsAvailableCount: number | null; + rateLimitResetCredits: CodexRateLimitResetCredit[]; + rateLimitResetCreditsError: string; + windows: CodexQuotaWindow[]; +}; + const QUOTA_PROGRESS_HIGH_THRESHOLD = 70; const QUOTA_PROGRESS_MEDIUM_THRESHOLD = 30; +const CODEX_RESET_CREDITS_REQUEST_TIMEOUT_MS = 8000; export interface QuotaStore { antigravityQuota: Record; @@ -502,15 +522,70 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex return windows; }; -const fetchCodexQuota = async ( - file: AuthFileItem, +const buildCodexRequestHeader = (file: AuthFileItem): Record => { + const accountId = resolveCodexChatgptAccountId(file); + const requestHeader: Record = { + ...CODEX_REQUEST_HEADERS, + }; + if (accountId) { + requestHeader['Chatgpt-Account-Id'] = accountId; + } + return requestHeader; +}; + +const fetchCodexResetCredits = async ( + authIndex: string, + requestHeader: Record, t: TFunction -): Promise<{ - planType: string | null; - subscriptionActiveUntil: string | number | null; - rateLimitResetCreditsAvailableCount: number | null; - windows: CodexQuotaWindow[]; -}> => { +): Promise => { + try { + const result = await apiCallApi.request( + { + authIndex, + method: 'GET', + url: CODEX_RATE_LIMIT_RESET_CREDITS_URL, + header: { + ...requestHeader, + Accept: 'application/json', + 'OpenAI-Beta': 'codex-1', + Originator: 'Codex Desktop', + }, + }, + { timeout: CODEX_RESET_CREDITS_REQUEST_TIMEOUT_MS } + ); + + if (result.statusCode < 200 || result.statusCode >= 300) { + return { + availableCount: null, + credits: [], + error: getApiCallErrorMessage(result), + }; + } + + const summary = normalizeCodexResetCreditsPayload(result.body ?? result.bodyText); + if (summary.invalidPayload) { + return { + availableCount: null, + credits: [], + error: t('codex_quota.reset_credits_invalid_payload'), + }; + } + + return { + availableCount: summary.availableCount, + credits: summary.credits, + error: '', + }; + } catch (err: unknown) { + return { + availableCount: null, + credits: [], + error: err instanceof Error ? err.message : t('common.unknown_error'), + }; + } +}; + +const fetchCodexQuota = async (file: AuthFileItem, t: TFunction): Promise => { const rawAuthIndex = file['auth_index'] ?? file.authIndex; const authIndex = normalizeAuthIndex(rawAuthIndex); if (!authIndex) { @@ -519,14 +594,7 @@ const fetchCodexQuota = async ( const planTypeFromFile = resolveCodexPlanType(file); const subscriptionActiveUntil = resolveCodexSubscriptionActiveUntil(file); - const accountId = resolveCodexChatgptAccountId(file); - - const requestHeader: Record = { - ...CODEX_REQUEST_HEADERS, - }; - if (accountId) { - requestHeader['Chatgpt-Account-Id'] = accountId; - } + const requestHeader = buildCodexRequestHeader(file); const result = await apiCallApi.request({ authIndex, @@ -546,15 +614,24 @@ const fetchCodexQuota = async ( const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType); const resetCredits = payload.rate_limit_reset_credits ?? payload.rateLimitResetCredits ?? null; - const rateLimitResetCreditsAvailableCount = normalizeNumberValue( + const usageResetCreditsAvailableCount = normalizeNumberValue( resetCredits?.available_count ?? resetCredits?.availableCount ); + const resetCreditsData = await fetchCodexResetCredits(authIndex, requestHeader, t); + const resetCreditsCountFromDetails = + resetCreditsData.credits.length > 0 ? resetCreditsData.credits.length : null; + const rateLimitResetCreditsAvailableCount = + resetCreditsData.availableCount ?? + resetCreditsCountFromDetails ?? + usageResetCreditsAvailableCount; const planType = planTypeFromUsage ?? planTypeFromFile; const windows = buildCodexQuotaWindows(payload, t); return { planType, subscriptionActiveUntil, rateLimitResetCreditsAvailableCount, + rateLimitResetCredits: resetCreditsData.credits, + rateLimitResetCreditsError: resetCreditsData.error, windows, }; }; @@ -581,13 +658,7 @@ const consumeCodexRateLimitResetCredit = async ( throw new Error(t('codex_quota.missing_auth_index')); } - const accountId = resolveCodexChatgptAccountId(file); - const requestHeader: Record = { - ...CODEX_REQUEST_HEADERS, - }; - if (accountId) { - requestHeader['Chatgpt-Account-Id'] = accountId; - } + const requestHeader = buildCodexRequestHeader(file); const result = await apiCallApi.request({ authIndex, @@ -604,15 +675,7 @@ const consumeCodexRateLimitResetCredit = async ( } }; -const resetCodexQuota = async ( - file: AuthFileItem, - t: TFunction -): Promise<{ - planType: string | null; - subscriptionActiveUntil: string | number | null; - rateLimitResetCreditsAvailableCount: number | null; - windows: CodexQuotaWindow[]; -}> => { +const resetCodexQuota = async (file: AuthFileItem, t: TFunction): Promise => { await consumeCodexRateLimitResetCredit(file, t); return fetchCodexQuota(file, t); }; @@ -837,6 +900,8 @@ const renderCodexItems = ( const planType = quota.planType ?? null; const subscriptionActiveUntil = quota.subscriptionActiveUntil ?? null; const rateLimitResetCreditsAvailableCount = quota.rateLimitResetCreditsAvailableCount ?? null; + const rateLimitResetCredits = quota.rateLimitResetCredits ?? []; + const rateLimitResetCreditsError = quota.rateLimitResetCreditsError ?? ''; const getPlanLabel = (pt?: string | null): string | null => { const normalized = normalizePlanType(pt); @@ -895,6 +960,49 @@ const renderCodexItems = ( nodes.push(h('div', { key: 'plan', className: styleMap.codexPlan }, ...planNodes)); } + if (rateLimitResetCredits.length > 0) { + nodes.push( + h( + 'div', + { key: 'reset-credit-expiries', className: styleMap.codexResetCredits }, + h( + 'div', + { className: styleMap.codexResetCreditsTitle }, + t('codex_quota.reset_credits_expiry_label') + ), + ...rateLimitResetCredits.map((credit, index) => + h( + 'div', + { + key: credit.id || `${credit.expiresAt}-${index}`, + className: styleMap.codexResetCreditRow, + }, + h( + 'span', + { className: styleMap.codexResetCreditLabel }, + t('codex_quota.reset_credit_number', { index: index + 1 }) + ), + h( + 'span', + { className: styleMap.codexResetCreditTime }, + formatShanghaiDateTime(credit.expiresAt) || credit.expiresAt + ) + ) + ) + ) + ); + } else if (rateLimitResetCreditsError) { + nodes.push( + h( + 'div', + { key: 'reset-credit-expiry-error', className: styleMap.codexResetCreditsError }, + t('codex_quota.reset_credits_expiry_failed', { + message: rateLimitResetCreditsError, + }) + ) + ); + } + if (windows.length === 0) { nodes.push( h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows')) @@ -1215,15 +1323,7 @@ export const ANTIGRAVITY_CONFIG: QuotaConfig = { +export const CODEX_CONFIG: QuotaConfig = { type: 'codex', i18nPrefix: 'codex_quota', cardIdleMessageKey: 'quota_management.card_idle_hint', @@ -1233,17 +1333,26 @@ export const CODEX_CONFIG: QuotaConfig< canResetQuota: (quota) => (quota.rateLimitResetCreditsAvailableCount ?? 0) > 0, storeSelector: (state) => state.codexQuota, storeSetter: 'setCodexQuota', - buildLoadingState: () => ({ status: 'loading', windows: [] }), + buildLoadingState: () => ({ + status: 'loading', + windows: [], + rateLimitResetCredits: [], + rateLimitResetCreditsError: '', + }), buildSuccessState: (data) => ({ status: 'success', windows: data.windows, planType: data.planType, subscriptionActiveUntil: data.subscriptionActiveUntil, rateLimitResetCreditsAvailableCount: data.rateLimitResetCreditsAvailableCount, + rateLimitResetCredits: data.rateLimitResetCredits, + rateLimitResetCreditsError: data.rateLimitResetCreditsError, }), buildErrorState: (message, status) => ({ status: 'error', windows: [], + rateLimitResetCredits: [], + rateLimitResetCreditsError: '', error: message, errorStatus: status, }), diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4e967f51..923bf6b3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -472,6 +472,10 @@ "plan_label": "Plan", "expires_label": "Renewal time", "reset_credits_label": "Manual resets", + "reset_credits_expiry_label": "Manual reset expiry (Shanghai)", + "reset_credit_number": "Reset {{index}}", + "reset_credits_expiry_failed": "Failed to load manual reset expiry: {{message}}", + "reset_credits_invalid_payload": "Invalid manual reset expiry response", "reset_button": "Reset quota", "reset_confirm_title": "Reset Codex quota", "reset_confirm_message": "This will consume 1 manual reset to reset the Codex quota for \"{{name}}\". Continue?", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1d5db126..56a90a2d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -465,6 +465,11 @@ "additional_team_secondary_window": "{{name}}: месячный лимит", "plan_label": "Тариф", "expires_label": "Истекает", + "reset_credits_label": "Ручные сбросы", + "reset_credits_expiry_label": "Срок действия ручных сбросов (Шанхай)", + "reset_credit_number": "Сброс {{index}}", + "reset_credits_expiry_failed": "Не удалось загрузить срок действия ручных сбросов: {{message}}", + "reset_credits_invalid_payload": "Недопустимый ответ о сроке действия ручных сбросов", "plan_plus": "Plus", "plan_team": "Team", "plan_free": "Free", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a9d295bd..9a545515 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -472,6 +472,10 @@ "plan_label": "套餐", "expires_label": "续期时间", "reset_credits_label": "主动重置次数", + "reset_credits_expiry_label": "主动重置过期时间(上海)", + "reset_credit_number": "第 {{index}} 次", + "reset_credits_expiry_failed": "主动重置过期时间获取失败:{{message}}", + "reset_credits_invalid_payload": "主动重置过期时间响应格式无效", "reset_button": "重置额度", "reset_confirm_title": "重置 Codex 额度", "reset_confirm_message": "将消耗 1 次主动重置次数来重置 \"{{name}}\" 的 Codex 额度。是否继续?", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 242eaf64..5eee2326 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -472,6 +472,10 @@ "plan_label": "方案", "expires_label": "續期時間", "reset_credits_label": "主動重置次數", + "reset_credits_expiry_label": "主動重置過期時間(上海)", + "reset_credit_number": "第 {{index}} 次", + "reset_credits_expiry_failed": "主動重置過期時間取得失敗:{{message}}", + "reset_credits_invalid_payload": "主動重置過期時間回應格式無效", "reset_button": "重置配額", "reset_confirm_title": "重置 Codex 配額", "reset_confirm_message": "將消耗 1 次主動重置次數來重置「{{name}}」的 Codex 配額。是否繼續?", diff --git a/src/pages/QuotaPage.module.scss b/src/pages/QuotaPage.module.scss index e3f405d1..b1d83d6c 100644 --- a/src/pages/QuotaPage.module.scss +++ b/src/pages/QuotaPage.module.scss @@ -485,6 +485,58 @@ min-width: 0; } +.codexResetCredits { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 2px; +} + +.codexResetCreditsTitle { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + line-height: 1.35; +} + +.codexResetCreditRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; + padding: 6px 8px; + border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + border-radius: $radius-sm; + background-color: color-mix(in srgb, var(--bg-secondary) 72%, transparent); + font-size: 12px; + line-height: 1.35; + min-width: 0; +} + +.codexResetCreditLabel { + color: var(--text-tertiary); + flex: 0 0 auto; + white-space: nowrap; +} + +.codexResetCreditTime { + color: var(--text-primary); + font-weight: 600; + text-align: right; + overflow-wrap: anywhere; + min-width: 0; +} + +.codexResetCreditsError { + font-size: 12px; + line-height: 1.4; + color: var(--warning-text); + background-color: var(--warning-bg); + border: 1px solid var(--warning-border); + border-radius: $radius-sm; + padding: $spacing-xs $spacing-sm; +} + .codexPlanSeparator { width: 1px; height: 12px; diff --git a/src/types/quota.ts b/src/types/quota.ts index 2d966561..a3254a82 100644 --- a/src/types/quota.ts +++ b/src/types/quota.ts @@ -67,6 +67,13 @@ export interface CodexRateLimitResetCredits { availableCount?: number | string; } +export interface CodexRateLimitResetCredit { + id: string; + status: string; + grantedAt: string; + expiresAt: string; +} + export interface CodexUsagePayload { plan_type?: string; planType?: string; @@ -190,6 +197,8 @@ export interface CodexQuotaState { planType?: string | null; subscriptionActiveUntil?: string | number | null; rateLimitResetCreditsAvailableCount?: number | null; + rateLimitResetCredits?: CodexRateLimitResetCredit[]; + rateLimitResetCreditsError?: string; error?: string; errorStatus?: number; } diff --git a/src/utils/quota/constants.ts b/src/utils/quota/constants.ts index df7e33eb..dcede559 100644 --- a/src/utils/quota/constants.ts +++ b/src/utils/quota/constants.ts @@ -99,6 +99,8 @@ export const CLAUDE_USAGE_WINDOW_KEYS = [ // Codex API configuration export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; +export const CODEX_RATE_LIMIT_RESET_CREDITS_URL = + 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits'; export const CODEX_RATE_LIMIT_RESET_CREDITS_CONSUME_URL = 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume'; diff --git a/src/utils/quota/index.ts b/src/utils/quota/index.ts index 6a10fa3f..1c8a5d3e 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 './resetCredits'; diff --git a/src/utils/quota/resetCredits.ts b/src/utils/quota/resetCredits.ts new file mode 100644 index 00000000..c63d4b81 --- /dev/null +++ b/src/utils/quota/resetCredits.ts @@ -0,0 +1,111 @@ +export interface CodexResetCredit { + id: string; + status: string; + grantedAt: string; + expiresAt: string; +} + +export interface CodexResetCreditsSummary { + availableCount: number | null; + credits: CodexResetCredit[]; + invalidPayload: boolean; +} + +const SHANGHAI_TIME_FORMATTER = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, +}); + +const asRecord = (value: unknown): Record | null => + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; + +const normalizeStringValue = (value: unknown): string | null => { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return value.toString(); + } + return null; +}; + +const normalizeNumberValue = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +const normalizeCredit = (value: unknown): CodexResetCredit | null => { + const record = asRecord(value); + if (!record) return null; + if (normalizeStringValue(record.reset_type ?? record.resetType) !== 'codex_rate_limits') { + return null; + } + if (normalizeStringValue(record.status) !== 'available') { + return null; + } + + const expiresAt = normalizeStringValue(record.expires_at ?? record.expiresAt); + if (!expiresAt) return null; + + return { + id: normalizeStringValue(record.id) ?? '', + status: normalizeStringValue(record.status) ?? '', + grantedAt: normalizeStringValue(record.granted_at ?? record.grantedAt) ?? '', + expiresAt, + }; +}; + +export const normalizeCodexResetCreditsPayload = (payload: unknown): CodexResetCreditsSummary => { + let parsedPayload = payload; + if (typeof payload === 'string') { + const trimmed = payload.trim(); + if (!trimmed) { + return { availableCount: null, credits: [], invalidPayload: true }; + } + try { + parsedPayload = JSON.parse(trimmed); + } catch { + return { availableCount: null, credits: [], invalidPayload: true }; + } + } + + const record = asRecord(parsedPayload); + if (!record) { + return { availableCount: null, credits: [], invalidPayload: true }; + } + + const hasExpectedShape = + 'credits' in record || 'available_count' in record || 'availableCount' in record; + const credits = Array.isArray(record.credits) + ? record.credits + .map((item) => normalizeCredit(item)) + .filter((item): item is CodexResetCredit => Boolean(item)) + : []; + + return { + availableCount: normalizeNumberValue(record.available_count ?? record.availableCount), + credits, + invalidPayload: !hasExpectedShape, + }; +}; + +export const formatShanghaiDateTime = (value: string): string => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return SHANGHAI_TIME_FORMATTER.format(date).replace(',', ''); +};