mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-09-21 06:06:24 +08:00
Merge pull request #330 from xionghaizhi/feat/codex-reset-credit-expiries
feat(quota): show Codex reset credit expiries
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 额度。是否继续?",
|
||||
|
||||
@@ -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 配額。是否繼續?",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './resolvers';
|
||||
export * from './formatters';
|
||||
export * from './validators';
|
||||
export * from './builders';
|
||||
export * from './resetCredits';
|
||||
|
||||
@@ -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(',', '');
|
||||
};
|
||||
Reference in New Issue
Block a user