Merge pull request #330 from xionghaizhi/feat/codex-reset-credit-expiries

feat(quota): show Codex reset credit expiries
This commit is contained in:
Supra4E8C
2026-06-22 20:32:27 +08:00
committed by GitHub
10 changed files with 344 additions and 43 deletions
+152 -43
View File
@@ -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<string, AntigravityQuotaState>;
@@ -502,15 +522,70 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
return windows;
};
const fetchCodexQuota = async (
file: AuthFileItem,
const buildCodexRequestHeader = (file: AuthFileItem): Record<string, string> => {
const accountId = resolveCodexChatgptAccountId(file);
const requestHeader: Record<string, string> = {
...CODEX_REQUEST_HEADERS,
};
if (accountId) {
requestHeader['Chatgpt-Account-Id'] = accountId;
}
return requestHeader;
};
const fetchCodexResetCredits = async (
authIndex: string,
requestHeader: Record<string, string>,
t: TFunction
): Promise<{
planType: string | null;
subscriptionActiveUntil: string | number | null;
rateLimitResetCreditsAvailableCount: number | null;
windows: CodexQuotaWindow[];
}> => {
): Promise<CodexResetCreditsData> => {
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<CodexQuotaData> => {
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<string, string> = {
...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<string, string> = {
...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<CodexQuotaData> => {
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<AntigravityQuotaState, AntigravityQ
renderQuotaItems: renderAntigravityItems,
};
export const CODEX_CONFIG: QuotaConfig<
CodexQuotaState,
{
planType: string | null;
subscriptionActiveUntil: string | number | null;
rateLimitResetCreditsAvailableCount: number | null;
windows: CodexQuotaWindow[];
}
> = {
export const CODEX_CONFIG: QuotaConfig<CodexQuotaState, CodexQuotaData> = {
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,
}),
+4
View File
@@ -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?",
+5
View File
@@ -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",
+4
View File
@@ -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 额度。是否继续?",
+4
View File
@@ -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 配額。是否繼續?",
+52
View File
@@ -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;
+9
View File
@@ -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;
}
+2
View File
@@ -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';
+1
View File
@@ -8,3 +8,4 @@ export * from './resolvers';
export * from './formatters';
export * from './validators';
export * from './builders';
export * from './resetCredits';
+111
View File
@@ -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<string, unknown> | null =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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(',', '');
};