diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 3e78c04..fe85933 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -133,6 +133,16 @@ export const jaOverrides = { 'quota.resetCredits': '手動リセット回数', 'quota.earliestExpiry': '最も早い有効期限', 'quota.remaining': '残り {percent}%', + 'quota.resetPassed': 'リセット時刻到達。更新して確認してください', + 'quota.subscriptionExpiry': 'サブスクリプション期限:{time}', + 'quota.resetApplicable': '現在適用可能:{count} 回', + 'quota.resetCreditsWarning': 'リセット回数の詳細取得に失敗:{error}', + 'quota.xaiProbeConfirm': 'xAI 有料アカウントはこの請求クォータ API に対応していません。短い grok-4.5 リクエストで利用可能か確認しますか?少額の料金が発生する可能性があり、残りクォータは取得できません。', + 'quota.service.window.sevenDayFable': '7 日間 Fable 枠', + 'quota.service.xaiPaidHealth': '有料 API 利用可能', + 'quota.service.xaiPaidHealthDetail': '短い会話テストに成功。残りクォータは提供されていません', + 'quota.service.error.xaiProbeRequired': '有料 API テストには確認が必要です。課金リクエストは送信していません', + 'quota.service.error.resetCreditsInvalid': 'リセット回数 API のデータ形式を認識できません', 'quota.confirm.title': '「{name}」の Codex クォータをリセットしますか?', 'quota.confirm.cost': 'この操作では手動リセットを 1 回消費します。', 'quota.confirm.available': '現在利用可能:{count} 回', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4a9f7f4..a5496fd 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -170,6 +170,16 @@ export const en: Record = { 'quota.resetCredits': 'Manual resets', 'quota.earliestExpiry': 'Earliest expiry', 'quota.remaining': '{percent}% remaining', + 'quota.resetPassed': 'Reset time reached; refresh to verify', + 'quota.subscriptionExpiry': 'Subscription expires: {time}', + 'quota.resetApplicable': 'Currently applicable: {count}', + 'quota.resetCreditsWarning': 'Reset credit details unavailable: {error}', + 'quota.xaiProbeConfirm': 'Paid xAI accounts do not expose this billing quota API. Send a tiny grok-4.5 request to check availability? This may incur a small charge and does not report remaining quota.', + 'quota.service.window.sevenDayFable': '7-day Fable window', + 'quota.service.xaiPaidHealth': 'Paid API available', + 'quota.service.xaiPaidHealthDetail': 'Tiny chat probe succeeded; remaining quota is not provided', + 'quota.service.error.xaiProbeRequired': 'Paid API health probing requires confirmation; no billable request was sent', + 'quota.service.error.resetCreditsInvalid': 'The reset credits endpoint returned unrecognized data', 'quota.confirm.title': 'Reset the Codex quota for “{name}”?', 'quota.confirm.cost': 'This will consume one manual reset credit.', 'quota.confirm.available': 'Currently available: {count}', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 49188a3..7c67b44 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -168,6 +168,16 @@ export const zhCN = { 'quota.resetCredits': '主动重置次数', 'quota.earliestExpiry': '最早过期', 'quota.remaining': '剩余 {percent}%', + 'quota.resetPassed': '重置时间已到,请刷新确认', + 'quota.subscriptionExpiry': '订阅到期:{time}', + 'quota.resetApplicable': '当前适用:{count} 次', + 'quota.resetCreditsWarning': '重置次数详情查询失败:{error}', + 'quota.xaiProbeConfirm': 'xAI 付费账户不提供此账单额度接口。是否发送极短的 grok-4.5 请求检测可用性?这可能产生少量费用,且不会返回剩余额度。', + 'quota.service.window.sevenDayFable': '7 天 Fable 窗口', + 'quota.service.xaiPaidHealth': '付费 API 可用', + 'quota.service.xaiPaidHealthDetail': '极短对话探测成功;上游未提供剩余额度', + 'quota.service.error.xaiProbeRequired': '付费 API 可用性探测需要确认,尚未发送计费请求', + 'quota.service.error.resetCreditsInvalid': '重置次数接口返回了无法识别的数据', 'quota.confirm.title': '确认重置“{name}”的 Codex 额度吗?', 'quota.confirm.cost': '本操作将消耗 1 次主动重置机会。', 'quota.confirm.available': '当前可用:{count} 次', diff --git a/src/pages/AuthFileManagementPage.tsx b/src/pages/AuthFileManagementPage.tsx index cf4e345..f6cee10 100644 --- a/src/pages/AuthFileManagementPage.tsx +++ b/src/pages/AuthFileManagementPage.tsx @@ -38,6 +38,7 @@ import { import { captureQuotaCacheGeneration, commitQuotaCacheIfCurrent, + getQuotaCacheSnapshot, pruneQuotaCache, updateQuotaCache, useQuotaCache, @@ -57,6 +58,7 @@ import { type OAuthModelDefinition, } from '../services/oauthModels'; import { getCurrentLocale, translate, useI18n } from '../i18n'; +import { formatQuotaReset, useQuotaClock } from '../services/quotaTime'; type AuthFile = Record; @@ -102,6 +104,7 @@ const statusText = (file: AuthFile) => { function AuthFileQuotaSummary({ quota }: { quota: QuotaState }) { const { locale, t } = useI18n(); + const now = useQuotaClock() + (quota.serverTimeOffsetMs ?? 0); if (quota.status === 'loading') { return (
@@ -124,18 +127,23 @@ function AuthFileQuotaSummary({ quota }: { quota: QuotaState }) {
{quota.plan ? {quota.plan} : null} {quota.rows.length > 0 ? quota.rows.map((row, index) => { - const detail = [row.detail, row.reset].filter(Boolean).join(' · '); + const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now); + const detail = [row.detail, reset].filter(Boolean).join(' · '); return ( {row.label} {row.remainingPercent === null ? '—' : `${Math.round(row.remainingPercent)}%`} - {row.reset ? {row.reset} : null} + {reset ? {reset} : null} + {row.remainingPercent === null && row.detail ? {row.detail} : null} ); }) : {t('authFiles.quota.empty')}} {quota.resetCredits !== undefined ? ( {t('authFiles.quota.resets', { count: quota.resetCredits })} ) : null} + {quota.resetCreditsApplicable !== undefined ? {t('quota.resetApplicable', { count: quota.resetCreditsApplicable })} : null} + {quota.subscriptionActiveUntil ? {t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })} : null} + {quota.resetCreditsError ? {t('quota.resetCreditsWarning', { error: quota.resetCreditsError })} : null} {quota.resetCreditsEarliestExpiry ? ( {t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) })} ) : null} @@ -205,9 +213,10 @@ export function AuthFileManagementPage() { const refreshQuota = async (file: AuthFile) => { if (readBoolean(file, 'disabled')) return; const key = quotaKey(file); + if (getQuotaCacheSnapshot()[key]?.status === 'loading') return; const cacheGeneration = captureQuotaCacheGeneration(); updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } })); - const result = await loadQuota(file); + const result = await loadQuota(file, { confirmXaiPaidProbe: () => window.confirm(t('quota.xaiProbeConfirm')) }); commitQuotaCacheIfCurrent(cacheGeneration, () => { updateQuotaCache((current) => ({ ...current, [key]: result })); }); diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index 2f7717d..14f89c8 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -6,6 +6,7 @@ import codexIcon from '../assets/icons/codex.svg'; import grokIcon from '../assets/icons/grok.svg'; import kimiIcon from '../assets/icons/kimi-light.svg'; import { managementApi, readBoolean, responseList } from '../services/managementApi'; +import { formatQuotaReset, useQuotaClock } from '../services/quotaTime'; import { consumeCodexResetCredit, fileName, @@ -21,6 +22,7 @@ import { import { captureQuotaCacheGeneration, commitQuotaCacheIfCurrent, + getQuotaCacheSnapshot, pruneQuotaCache, updateQuotaCache, useQuotaCache, @@ -46,17 +48,17 @@ export function QuotaPage() { const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(''); + const querying = Object.values(quotas).some((quota) => quota.status === 'loading'); const loadFiles = useCallback(async () => { setLoading(true); setError(''); try { const payload = await managementApi.get('/auth-files'); - const nextFiles = dedupeAuthFiles(responseList(payload, 'files')).filter( - (file) => !readBoolean(file, 'disabled') && providerForFile(file), - ); + const allFiles = dedupeAuthFiles(responseList(payload, 'files')); + const nextFiles = allFiles.filter((file) => !readBoolean(file, 'disabled') && providerForFile(file)); setFiles(nextFiles); - const validQuotaKeys = new Set(nextFiles.map(quotaKey)); + const validQuotaKeys = new Set(allFiles.map(quotaKey)); pruneQuotaCache(validQuotaKeys); updateQuotaCache((current) => { const next = { ...current }; @@ -79,15 +81,17 @@ export function QuotaPage() { const refreshOne = useCallback(async (file: AuthFile) => { const key = quotaKey(file); + if (getQuotaCacheSnapshot()[key]?.status === 'loading') return; const cacheGeneration = captureQuotaCacheGeneration(); updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } })); - const result = await loadQuota(file); + const result = await loadQuota(file, { confirmXaiPaidProbe: () => window.confirm(t('quota.xaiProbeConfirm')) }); commitQuotaCacheIfCurrent(cacheGeneration, () => { updateQuotaCache((current) => ({ ...current, [key]: result })); }); - }, []); + }, [t]); const resetCodexQuota = useCallback(async (file: AuthFile, quota: QuotaState) => { + if (getQuotaCacheSnapshot()[quotaKey(file)]?.status === 'loading') return; const confirmed = window.confirm([ t('quota.confirm.title', { name: fileName(file) }), '', @@ -121,15 +125,23 @@ export function QuotaPage() { }, [locale, t]); const refreshAll = useCallback(async () => { + if (Object.values(getQuotaCacheSnapshot()).some((quota) => quota.status === 'loading')) return; setRefreshing(true); setError(''); const cacheGeneration = captureQuotaCacheGeneration(); - updateQuotaCache((current) => Object.fromEntries(files.map((file) => [quotaKey(file), { ...current[quotaKey(file)], status: 'loading', rows: [] }]))); + updateQuotaCache((current) => ({ + ...current, + ...Object.fromEntries(files.map((file) => [quotaKey(file), { + ...current[quotaKey(file)], status: 'loading', rows: [], + }])), + })); try { for (let index = 0; index < files.length; index += REFRESH_CONCURRENCY) { const batch = files.slice(index, index + REFRESH_CONCURRENCY); await Promise.all(batch.map(async (file) => { - const result = await loadQuota(file); + const result = await loadQuota(file, { + confirmXaiPaidProbe: () => window.confirm(`${fileName(file)}\n\n${t('quota.xaiProbeConfirm')}`), + }); commitQuotaCacheIfCurrent(cacheGeneration, () => { updateQuotaCache((current) => ({ ...current, [quotaKey(file)]: result })); }); @@ -138,7 +150,7 @@ export function QuotaPage() { } finally { setRefreshing(false); } - }, [files]); + }, [files, t]); const grouped = useMemo(() => { const groups = new Map(); @@ -161,10 +173,10 @@ export function QuotaPage() {
Quota

{t('quota.title')}

{t(files.length === 1 ? 'quota.queryableCredentials.one' : 'quota.queryableCredentials.other', { count: files.length })} - -
@@ -190,6 +202,7 @@ export function QuotaPage() { export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile; quota: QuotaState; onRefresh: () => void; onReset?: () => void }) { const { locale, t } = useI18n(); + const now = useQuotaClock() + (quota.serverTimeOffsetMs ?? 0); const provider = providerForFile(file); const name = fileName(file); const disabled = readBoolean(file, 'disabled'); @@ -199,8 +212,21 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile; {quota.status === 'idle' ?
{disabled ? t('quota.fileDisabled') : t('quota.notFetched')}
: null} {quota.status === 'loading' ?
{t('quota.querying')}
: null} {quota.status === 'error' ?
{quota.error}
: null} - {quota.status === 'success' && provider === 'codex' ?
{t('quota.resetCredits')} {quota.resetCredits ?? '—'}{t('quota.earliestExpiry')} {formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale)}
: null} - {quota.status === 'success' ?
{quota.rows.map((row, index) =>
{row.label}{row.remainingPercent === null ? '—' : t('quota.remaining', { percent: Math.round(row.remainingPercent) })}
{row.detail ?? ''}{row.reset ? `${row.detail ? ' · ' : ''}${row.reset}` : ''}
)}
: null} + {quota.status === 'success' && provider === 'codex' ?
+ {t('quota.resetCredits')} {quota.resetCredits ?? '—'} + {quota.resetCreditsApplicable !== undefined ? {t('quota.resetApplicable', { count: quota.resetCreditsApplicable })} : null} + {t('quota.earliestExpiry')} {formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale)} + {quota.subscriptionActiveUntil ? {t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })} : null} + {quota.resetCreditsError ? {t('quota.resetCreditsWarning', { error: quota.resetCreditsError })} : null} +
: null} + {quota.status === 'success' ?
{quota.rows.map((row, index) => { + const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now); + return
+
{row.label}{row.remainingPercent === null ? '—' : t('quota.remaining', { percent: Math.round(row.remainingPercent) })}
+ {row.remainingPercent !== null ?
: null} + {[row.detail, reset].filter(Boolean).join(' · ')} +
; + })}
: null} ); } diff --git a/src/services/quotaMetadata.ts b/src/services/quotaMetadata.ts new file mode 100644 index 0000000..1d42c2b --- /dev/null +++ b/src/services/quotaMetadata.ts @@ -0,0 +1,74 @@ +import { isRecord, readString } from './managementApi'; + +export const decodeQuotaToken = (value: unknown): Record | null => { + if (isRecord(value)) return value; + if (typeof value !== 'string' || !value.trim()) return null; + try { + const parsed = JSON.parse(value); + if (isRecord(parsed)) return parsed; + } catch { /* Try the JWT payload next. */ } + const segment = value.trim().split('.')[1]; + if (!segment) return null; + try { + const normalized = segment.replace(/-/g, '+').replace(/_/g, '/') + .padEnd(Math.ceil(segment.length / 4) * 4, '='); + const bytes = Uint8Array.from(atob(normalized), (character) => character.charCodeAt(0)); + const parsed = JSON.parse(new TextDecoder().decode(bytes)); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +}; + +export const quotaAuthRecords = (file: Record) => + [file, file.metadata, file.attributes].filter(isRecord); + +export const codexMetadataFor = (file: Record) => { + const records = quotaAuthRecords(file).flatMap((record) => { + const tokens = [record.id_token, record.idToken].flatMap((token) => { + const payload = decodeQuotaToken(token); + if (!payload) return []; + return [payload, payload['https://api.openai.com/auth']].filter(isRecord); + }); + return [record, ...tokens]; + }); + const first = (...keys: string[]) => records.map((record) => readString(record, ...keys)).find(Boolean); + const expiry = first( + 'chatgpt_subscription_active_until', 'chatgptSubscriptionActiveUntil', + 'subscription_active_until', 'subscriptionActiveUntil', + ) || records.map((record) => readString(record.subscription, 'active_until', 'activeUntil')).find(Boolean); + return { + accountId: first('chatgpt_account_id', 'chatgptAccountId', 'account_id', 'accountId') || '', + plan: first('plan_type', 'planType', 'chatgpt_plan_type', 'chatgptPlanType')?.toLowerCase(), + subscriptionActiveUntil: expiry && expiry !== '0' ? expiry : undefined, + }; +}; + +export const antigravityProjectFor = (file: Record): string => { + const records = [...quotaAuthRecords(file), file.installed, file.web].filter(isRecord); + return records.map((record) => readString(record, 'project_id', 'projectId', 'gemini_virtual_project')) + .find(Boolean) || ''; +}; + +export const isPaidXaiFile = (file: Record): boolean => { + const records: Record[] = []; + const visited = new Set>(); + const visit = (value: unknown, depth: number) => { + if (!isRecord(value) || visited.has(value) || depth > 2) return; + visited.add(value); + records.push(value); + for (const key of ['metadata', 'attributes', 'oauth', 'raw', 'credential', 'auth']) { + visit(value[key], depth + 1); + } + }; + visit(file, 0); + const usingApi = records.some((record) => + ['true', '1', 'yes', 'y', 'on'].includes(String(record.using_api ?? record.usingApi).trim().toLowerCase())); + if (usingApi && records.some((record) => readString(record, 'prefix').toLowerCase() === 'paid')) return true; + return records.some((record) => ['access_token', 'accessToken', 'id_token', 'idToken', 'token'].some((key) => { + const payload = decodeQuotaToken(record[key]); + if (!payload) return false; + return Object.entries(payload).some(([claim, tier]) => + /(?:^|[/:])tier$/i.test(claim) && (typeof tier === 'number' || typeof tier === 'string') && Number(tier) >= 1); + })); +}; diff --git a/src/services/quotaService.ts b/src/services/quotaService.ts index 3791618..b66c09c 100644 --- a/src/services/quotaService.ts +++ b/src/services/quotaService.ts @@ -6,6 +6,8 @@ import { readString, } from './managementApi'; import { authFileName } from './authFiles'; +import { antigravityProjectFor, codexMetadataFor, isPaidXaiFile } from './quotaMetadata'; +import { quotaResetFor, quotaResetInstant } from './quotaTime'; import { getCurrentLocale, translate, type AppLocale } from '../i18n'; const quotaText = ( @@ -20,6 +22,7 @@ export type QuotaRow = { label: string; remainingPercent: number | null; reset?: string; + resetAtMs?: number; detail?: string; }; export type QuotaState = { @@ -28,7 +31,11 @@ export type QuotaState = { error?: string; plan?: string; resetCredits?: number; + resetCreditsApplicable?: number; + resetCreditsError?: string; resetCreditsEarliestExpiry?: string; + subscriptionActiveUntil?: string; + serverTimeOffsetMs?: number; fetchedAt?: number; }; @@ -60,7 +67,7 @@ const headersByProvider: Record> = { codex: { Authorization: 'Bearer $TOKEN$', 'Content-Type': 'application/json', - 'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal', + 'User-Agent': 'codex-tui/0.149.1 (Mac OS 26.5.2; arm64) iTerm.app/3.6.11 (codex-tui; 0.149.1)', }, kimi: { Authorization: 'Bearer $TOKEN$' }, xai: { @@ -78,7 +85,8 @@ const headersByProvider: Record> = { }; export const providerForFile = (file: AuthFile): QuotaProvider | null => { - const value = readString(file, 'provider', 'type', 'account_type').toLowerCase(); + const value = readString(file, 'provider', 'type', 'account_type').toLowerCase().replace(/_/g, '-'); + if (value === 'x-ai' || value === 'grok') return 'xai'; if (value === 'anthropic') return 'claude'; if (value === 'anti-gravity') return 'antigravity'; return ['claude', 'codex', 'kimi', 'xai', 'antigravity'].includes(value) @@ -102,7 +110,7 @@ const parseBody = (value: unknown): unknown => { const numberValue = (value: unknown): number | null => { if (isRecord(value) && 'val' in value) return numberValue(value.val); - if (value === null || value === undefined || typeof value === 'boolean') return null; + if (typeof value !== 'number' && typeof value !== 'string') return null; if (typeof value === 'string' && !value.trim()) return null; const parsed = typeof value === 'number' ? value : Number(value); return Number.isFinite(parsed) ? parsed : null; @@ -141,8 +149,9 @@ const formatDateTime = (date: Date): string | undefined => { export const formatQuotaTimestamp = (value: string | undefined, locale: AppLocale = 'zh-CN'): string => { if (!value) return '—'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return '—'; + const ms = quotaResetInstant(value); + if (ms === undefined) return '—'; + const date = new Date(ms); return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', @@ -154,13 +163,8 @@ export const formatQuotaTimestamp = (value: string | undefined, locale: AppLocal }; const absoluteResetLabel = (value: unknown): string | undefined => { - if (value === null || value === undefined || value === '') return undefined; - const numeric = numberValue(value); - const numericText = typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value.trim()); - const date = typeof value === 'number' || numericText - ? new Date((numeric ?? 0) < 1e12 ? (numeric ?? 0) * 1000 : (numeric ?? 0)) - : new Date(String(value)); - return formatDateTime(date); + const ms = quotaResetInstant(value); + return ms === undefined ? undefined : formatDateTime(new Date(ms)); }; const relativeResetLabel = (value: unknown): string | undefined => { @@ -213,7 +217,6 @@ const codexWindowLabel = ( duration: number | null, prefix: string, kind: 'primary' | 'secondary', - teamPlan: boolean, ) => { if (duration === FIVE_HOUR_SECONDS) return `${prefix}${quotaText('quota.service.limit.fiveHours')}`; if (duration === WEEK_SECONDS) return `${prefix}${quotaText('quota.service.limit.week')}`; @@ -224,7 +227,7 @@ const codexWindowLabel = ( return `${prefix}${quotaText('quota.service.limit.duration', { duration: formatWindowDuration(duration) })}`; } if (kind === 'primary') return `${prefix}${quotaText('quota.service.limit.fiveHours')}`; - return `${prefix}${quotaText(teamPlan ? 'quota.service.limit.month' : 'quota.service.limit.week')}`; + return `${prefix}${quotaText('quota.service.limit.week')}`; }; const codexWindowRows = (value: Record): QuotaRow[] => { @@ -236,10 +239,17 @@ const codexWindowRows = (value: Record): QuotaRow[] => { }> = []; const addRateLimit = (rawRateLimit: unknown, prefix: string) => { if (!isRecord(rawRateLimit)) return; - windows.push( + const entries: typeof windows = [ { raw: rawRateLimit.primary_window ?? rawRateLimit.primaryWindow, kind: 'primary', prefix, source: rawRateLimit }, { raw: rawRateLimit.secondary_window ?? rawRateLimit.secondaryWindow, kind: 'secondary', prefix, source: rawRateLimit }, - ); + ]; + const order = ({ raw, kind }: typeof entries[number]) => { + const duration = isRecord(raw) ? numberValue(raw.limit_window_seconds ?? raw.limitWindowSeconds) : null; + if (duration === FIVE_HOUR_SECONDS) return 0; + if (duration === WEEK_SECONDS || (duration !== null && duration >= MIN_MONTH_SECONDS && duration <= MAX_MONTH_SECONDS)) return 1; + return kind === 'primary' ? 0 : 1; + }; + windows.push(...entries.sort((a, b) => order(a) - order(b))); }; addRateLimit(value.rate_limit ?? value.rateLimit, ''); @@ -257,15 +267,17 @@ const codexWindowRows = (value: Record): QuotaRow[] => { }); } - const teamPlan = readString(value, 'plan_type', 'planType').toLowerCase() === 'team'; return windows.map(({ raw, kind, prefix, source }): QuotaRow | null => { if (!isRecord(raw)) return null; const duration = numberValue(raw.limit_window_seconds ?? raw.limitWindowSeconds); const reached = source.limit_reached === true || source.limitReached === true || source.allowed === false; + const resetAtMs = quotaResetFor(raw, ['reset_at', 'resetAt'], ['reset_after_seconds', 'resetAfterSeconds']); return { - label: codexWindowLabel(duration, prefix, kind, teamPlan), - remainingPercent: remainingFromUsedPercent(raw.used_percent ?? raw.usedPercent) ?? (reached ? 0 : null), + label: codexWindowLabel(duration, prefix, kind), + remainingPercent: remainingFromUsedPercent(raw.used_percent ?? raw.usedPercent) + ?? (reached && resetAtMs !== undefined ? 0 : null), reset: codexResetLabel(raw), + resetAtMs, }; }).filter((row): row is QuotaRow => row !== null); }; @@ -285,7 +297,7 @@ export const codexResetCreditsFor = (payload: unknown): number | undefined => { export const codexResetCreditDetailsFor = ( payload: unknown, nowMs = Date.now(), -): { availableCount?: number; earliestExpiry?: string } => { +): { availableCount?: number; applicableAvailableCount?: number; earliestExpiry?: string } => { const value = parseBody(payload); if (!isRecord(value)) return {}; const credits = Array.isArray(value.credits) @@ -297,9 +309,14 @@ export const codexResetCreditDetailsFor = ( ) : []; const availableCount = numberValue(value.available_count ?? value.availableCount); - const earliestExpiry = credits + const applicableCount = numberValue(value.applicable_available_count ?? value.applicableAvailableCount); + const validCredits = credits.filter((credit) => { + const expiry = quotaResetInstant(credit.expires_at ?? credit.expiresAt); + return expiry !== undefined && expiry > nowMs; + }); + const earliestExpiry = validCredits .map((credit) => readString(credit, 'expires_at', 'expiresAt')) - .map((expiresAt) => ({ expiresAt, expiresAtMs: new Date(expiresAt).getTime() })) + .map((expiresAt) => ({ expiresAt, expiresAtMs: quotaResetInstant(expiresAt) ?? NaN })) .filter((credit) => credit.expiresAt && Number.isFinite(credit.expiresAtMs) @@ -308,9 +325,11 @@ export const codexResetCreditDetailsFor = ( .sort((left, right) => left.expiresAtMs - right.expiresAtMs)[0]?.expiresAt; return { + // An empty detail list is not an explicit zero; keep the usage-summary fallback. availableCount: availableCount === null - ? credits.length || undefined + ? validCredits.length || undefined : Math.max(0, Math.floor(availableCount)), + ...(applicableCount === null ? {} : { applicableAvailableCount: Math.max(0, Math.floor(applicableCount)) }), earliestExpiry, }; }; @@ -329,19 +348,35 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo seven_day_opus: quotaText('quota.service.window.sevenDayOpus'), seven_day_sonnet: quotaText('quota.service.window.sevenDaySonnet'), seven_day_cowork: quotaText('quota.service.window.sevenDayCowork'), - iguana_necktie: 'Iguana Necktie', + iguana_necktie: quotaText('quota.service.window.sevenDayFable'), }; - const rows = Object.entries(value) - .filter(([key]) => key in labels) - .map(([key, raw]): QuotaRow | null => { - if (!isRecord(raw)) return null; + const fableCandidates = (Array.isArray(value.limits) ? value.limits : []).filter(isRecord) + .filter((limit) => { + const scope = isRecord(limit.scope) ? limit.scope : null; + const name = readString(scope?.model, 'display_name', 'displayName').toLowerCase(); + return readString(limit, 'kind').toLowerCase() === 'weekly_scoped' + && ['fable', 'fable 5'].includes(name) && numberValue(limit.percent) !== null; + }); + const fable = fableCandidates.find((limit) => limit.is_active === true) ?? fableCandidates[0]; + const rows = Object.entries(labels) + .filter(([key]) => key !== 'iguana_necktie' || !fable) + .map(([key]): QuotaRow | null => { + const raw = value[key]; + if (!isRecord(raw) || !('utilization' in raw)) return null; return { label: labels[key], remainingPercent: remainingFromUsedPercent(raw.utilization), reset: absoluteResetLabel(raw.resets_at ?? raw.resetsAt), + resetAtMs: quotaResetFor(raw, ['resets_at', 'resetsAt']), }; }) .filter((row): row is QuotaRow => row !== null); + if (fable) rows.push({ + label: quotaText('quota.service.window.sevenDayFable'), + remainingPercent: remainingFromUsedPercent(fable.percent), + reset: absoluteResetLabel(fable.resets_at ?? fable.resetsAt), + resetAtMs: quotaResetFor(fable, ['resets_at', 'resetsAt']), + }); const extraUsage = isRecord(value.extra_usage) ? value.extra_usage : isRecord(value.extraUsage) @@ -369,11 +404,10 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo } if (provider === 'kimi') { - const items: unknown[] = []; + const items: unknown[] = Array.isArray(value.limits) ? [...value.limits] : []; if (isRecord(value.usage)) { - items.push({ ...value.usage, label: quotaText('quota.service.weekly') }); + items.push({ ...value.usage, label: readString(value.usage, 'name', 'title') || quotaText('quota.service.weekly') }); } - if (Array.isArray(value.limits)) items.push(...value.limits); return items .map((raw, index): QuotaRow | null => { if (!isRecord(raw)) return null; @@ -385,19 +419,21 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo if (usedValue === null && limit === null) return null; const window = isRecord(raw.window) ? raw.window : null; const duration = numberValue(window?.duration ?? raw.duration ?? detail.duration); - const unit = readString(window, 'timeUnit', 'time_unit') + const unit = (readString(window, 'timeUnit', 'time_unit') || readString(raw, 'timeUnit', 'time_unit') - || readString(detail, 'timeUnit', 'time_unit'); + || readString(detail, 'timeUnit', 'time_unit')).toLowerCase().replace(/^time_unit_/, ''); const durationText = duration !== null && duration > 0 - ? unit.toLowerCase().startsWith('day') - ? quotaText('quota.service.duration.days', { count: duration }) - : unit.toLowerCase().startsWith('hour') - ? quotaText('quota.service.duration.hours', { count: duration }) - : unit.toLowerCase().startsWith('second') - ? quotaText('quota.service.duration.seconds', { count: duration }) - : duration % 60 === 0 - ? quotaText('quota.service.duration.hours', { count: duration / 60 }) - : quotaText('quota.service.duration.minutes', { count: duration }) + ? unit.startsWith('week') + ? quotaText('quota.service.duration.days', { count: duration * 7 }) + : unit.startsWith('day') + ? quotaText('quota.service.duration.days', { count: duration }) + : unit.startsWith('hour') + ? quotaText('quota.service.duration.hours', { count: duration }) + : unit.startsWith('second') + ? quotaText('quota.service.duration.seconds', { count: duration }) + : duration % 60 === 0 + ? quotaText('quota.service.duration.hours', { count: duration / 60 }) + : quotaText('quota.service.duration.minutes', { count: duration }) : ''; const durationLabel = durationText ? quotaText('quota.service.window.duration', { duration: durationText }) @@ -420,6 +456,7 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo detail.reset_at ?? detail.resetAt ?? detail.reset_time ?? detail.resetTime, ) ?? relativeResetLabel(detail.reset_in ?? detail.resetIn ?? detail.ttl), + resetAtMs: quotaResetFor(detail, ['reset_at', 'resetAt', 'reset_time', 'resetTime'], ['reset_in', 'resetIn', 'ttl']), detail: limit === null ? undefined : `${usedValue ?? 0} / ${limit}`, }; }) @@ -427,6 +464,11 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo } if (provider === 'xai') { + if (value.mode === 'paid-health') return [{ + label: quotaText('quota.service.xaiPaidHealth'), + remainingPercent: null, + detail: quotaText('quota.service.xaiPaidHealthDetail'), + }]; const payloads = isRecord(value.weekly) || isRecord(value.monthly) ? [value.weekly, value.monthly] : [value]; @@ -443,11 +485,13 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo const periodType = readString(currentPeriod, 'type').toLowerCase(); const weeklyUsed = numberValue(config.credit_usage_percent ?? config.creditUsagePercent); - if (weeklyUsed !== null || periodType.includes('week')) { + const productItems = config.product_usage ?? config.productUsage; + if (weeklyUsed !== null || periodType.includes('week') || (Array.isArray(productItems) && productItems.length > 0)) { rows.push({ label: quotaText('quota.service.weekly'), remainingPercent: remainingFromUsedPercent(weeklyUsed), reset: absoluteResetLabel(currentPeriod?.end), + resetAtMs: currentPeriod ? quotaResetFor(currentPeriod, ['end']) : undefined, }); const productUsage = Array.isArray(config.product_usage) ? config.product_usage @@ -463,6 +507,7 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo item.usage_percent ?? item.usagePercent, ), reset: absoluteResetLabel(currentPeriod?.end), + resetAtMs: currentPeriod ? quotaResetFor(currentPeriod, ['end']) : undefined, }); }); } @@ -482,6 +527,7 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo label: quotaText('quota.service.monthlyIncluded'), remainingPercent: clampPercent(remaining), reset: absoluteResetLabel(config.billing_period_end ?? config.billingPeriodEnd), + resetAtMs: quotaResetFor(config, ['billing_period_end', 'billingPeriodEnd']), detail: limit !== null ? `${formatUsdFromCents(Math.max(0, limit - (includedUsed ?? 0)))} / ${formatUsdFromCents(limit)}` : undefined, @@ -499,6 +545,7 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo onDemandUsed === null ? null : ((onDemandCap - onDemandUsed) / onDemandCap) * 100, ), reset: absoluteResetLabel(config.billing_period_end ?? config.billingPeriodEnd), + resetAtMs: quotaResetFor(config, ['billing_period_end', 'billingPeriodEnd']), detail: `${formatUsdFromCents(Math.max(0, onDemandCap - (onDemandUsed ?? 0)))} / ${formatUsdFromCents(onDemandCap)}`, }); } @@ -513,10 +560,16 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo }); } - const groups = Array.isArray(value.groups) ? value.groups : []; + const nested = parseBody(value.body); + const summary = !Array.isArray(value.groups) && isRecord(nested) ? nested : value; + const groups = Array.isArray(summary.groups) ? summary.groups : []; return groups.flatMap((group) => { if (!isRecord(group) || !Array.isArray(group.buckets)) return []; - const buckets = group.buckets; + const order = (bucket: unknown) => { + const window = readString(bucket, 'window').toLowerCase(); + return ['5h', 'five-hour', 'five_hour'].includes(window) ? 0 : ['weekly', 'week'].includes(window) ? 1 : 2; + }; + const buckets = [...group.buckets].sort((a, b) => order(a) - order(b)); const groupLabel = readString(group, 'display_name', 'displayName') || quotaText('quota.service.quota'); const groupDescription = readString(group, 'description'); @@ -533,6 +586,7 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo label: label || quotaText('quota.service.quota.numbered', { index: index + 1 }), remainingPercent: remaining * 100, reset: absoluteResetLabel(bucket.reset_time ?? bucket.resetTime), + resetAtMs: quotaResetFor(bucket, ['reset_time', 'resetTime']), detail: readString(bucket, 'description') || groupDescription || undefined, }; }) @@ -540,66 +594,18 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo }); }; -const resolveProjectId = (file: AuthFile): string => - readString(file, 'project_id', 'projectId'); - -const decodeJwtPayload = (value: unknown): Record | null => { - if (isRecord(value)) return value; - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - if (!trimmed) return null; - try { - const parsed = JSON.parse(trimmed); - if (isRecord(parsed)) return parsed; - } catch { - // Continue with JWT payload decoding. - } - const segment = trimmed.split('.')[1]; - if (!segment) return null; - try { - const normalized = segment.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(segment.length / 4) * 4, '='); - const parsed = JSON.parse(atob(normalized)); - return isRecord(parsed) ? parsed : null; - } catch { - return null; - } -}; - -const codexAccountIdFromRecord = (record: Record): string => { - const direct = readString( - record, - 'chatgpt_account_id', - 'chatgptAccountId', - 'account_id', - 'accountId', - ); +const resolveProjectId = async (file: AuthFile): Promise => { + const direct = antigravityProjectFor(file); if (direct) return direct; - - for (const container of [record.metadata, record.attributes]) { - if (!isRecord(container)) continue; - const nestedDirect = readString( - container, - 'chatgpt_account_id', - 'chatgptAccountId', - 'account_id', - 'accountId', - ); - if (nestedDirect) return nestedDirect; + try { + const payload = parseBody(await managementApi.get('/auth-files/download', { name: fileName(file) })); + return isRecord(payload) ? antigravityProjectFor(payload) : ''; + } catch { + return ''; } - - for (const candidate of [record.id_token, record.idToken]) { - const payload = decodeJwtPayload(candidate); - const nested = payload && isRecord(payload['https://api.openai.com/auth']) ? payload['https://api.openai.com/auth'] : payload; - const accountId = nested - ? readString(nested, 'chatgpt_account_id', 'chatgptAccountId', 'account_id', 'accountId') - : ''; - if (accountId) return accountId; - } - return ''; }; -const resolveCodexAccountId = (file: AuthFile): string => - codexAccountIdFromRecord(file); +const resolveCodexAccountId = (file: AuthFile): string => codexMetadataFor(file).accountId; const xaiUserIdFromRecord = (record: Record): string => { const nestedRecords = [record, record.metadata, record.attributes] @@ -625,6 +631,8 @@ const requestQuotaPayload = async ( header: Record, method: 'GET' | 'POST' = 'GET', data?: string, + timeoutMs?: number, + responseClock?: { serverTimeOffsetMs?: number }, ) => { const response = await managementApi.post>('/api-call', { authIndex, @@ -632,17 +640,44 @@ const requestQuotaPayload = async ( url, header, data, - }); + }, { timeoutMs }); const status = Number(response.status_code ?? response.statusCode ?? 0); if (status < 200 || status >= 300) { throw new Error(apiCallErrorMessage(response)); } + if (responseClock) { + const header = isRecord(response.header) ? response.header : {}; + const raw = Object.entries(header).find(([key]) => key.toLowerCase() === 'date')?.[1]; + const serverTime = quotaResetInstant(Array.isArray(raw) ? raw[0] : raw); + responseClock.serverTimeOffsetMs = serverTime === undefined ? undefined : serverTime - Date.now(); + } return parseBody(response.body ?? response.bodyText); }; -const callXaiQuota = async (file: AuthFile): Promise => { +export type QuotaLoadOptions = { + // Paid health checks send a real (potentially billable) completion. Never do this silently. + confirmXaiPaidProbe?: () => boolean | Promise; +}; + +const callXaiPaidHealth = async (authIndex: string, options: QuotaLoadOptions): Promise => { + if (!await options.confirmXaiPaidProbe?.()) throw new Error(quotaText('quota.service.error.xaiProbeRequired')); + const header = { Authorization: 'Bearer $TOKEN$', accept: 'application/json' }; + const [profile, chat] = await Promise.allSettled([ + requestQuotaPayload(authIndex, 'https://api.x.ai/v1/me', header, 'GET', undefined, 15_000), + requestQuotaPayload(authIndex, 'https://api.x.ai/v1/chat/completions', { + ...header, 'Content-Type': 'application/json', + }, 'POST', JSON.stringify({ + model: 'grok-4.5', messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, stream: false, + }), 15_000), + ]); + if (chat.status === 'rejected') throw chat.reason; + return { mode: 'paid-health', plan_type: 'Paid', profile: profile.status === 'fulfilled' ? profile.value : null }; +}; + +const callXaiQuota = async (file: AuthFile, options: QuotaLoadOptions): Promise => { const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex); if (!authIndex) throw new Error(quotaText('quota.service.error.missingAuthIndex')); + if (isPaidXaiFile(file)) return callXaiPaidHealth(authIndex, options); const header = { ...headersByProvider.xai }; const userId = await resolveXaiUserId(file); if (userId) header['x-userid'] = userId; @@ -650,22 +685,28 @@ const callXaiQuota = async (file: AuthFile): Promise => { requestQuotaPayload(authIndex, XAI_WEEKLY_URL, header), requestQuotaPayload(authIndex, endpointByProvider.xai, header), ]); - if (weekly.status === 'rejected' && monthly.status === 'rejected') { - throw weekly.reason; - } - return { + const payload = { weekly: weekly.status === 'fulfilled' ? weekly.value : null, monthly: monthly.status === 'fulfilled' ? monthly.value : null, }; + if (quotaRowsFor('xai', payload).length > 0) return payload; + const billingError = weekly.status === 'rejected' && monthly.status === 'rejected' + ? weekly.reason : new Error(quotaText('quota.service.error.unrecognized')); + try { + return await callXaiPaidHealth(authIndex, options); + } catch { + // Keep the useful billing error if the alternative account mode also fails or is declined. + throw billingError; + } }; const booleanValue = (value: unknown): boolean | null => { if (typeof value === 'boolean') return value; - if (typeof value === 'number') return value !== 0; + if (typeof value === 'number') return Number.isFinite(value) ? value !== 0 : null; if (typeof value === 'string') { const normalized = value.trim().toLowerCase(); - if (['true', '1', 'yes', 'on'].includes(normalized)) return true; - if (['false', '0', 'no', 'off'].includes(normalized)) return false; + if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false; } return null; }; @@ -740,6 +781,7 @@ async function callUpstreamQuota( file: AuthFile, provider: QuotaProvider, resolvedCodexAccountId?: string, + responseClock?: { serverTimeOffsetMs?: number }, ): Promise { const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex); if (!authIndex) throw new Error(quotaText('quota.service.error.missingAuthIndex')); @@ -765,6 +807,8 @@ async function callUpstreamQuota( header, provider === 'antigravity' ? 'POST' : 'GET', project ? JSON.stringify({ project }) : undefined, + undefined, + provider === 'antigravity' ? responseClock : undefined, ); if (provider === 'antigravity') { hadSuccessfulResponse = true; @@ -790,7 +834,7 @@ async function callUpstreamQuota( const callCodexResetCredits = async ( file: AuthFile, accountId: string, -): Promise<{ availableCount?: number; earliestExpiry?: string }> => { +): Promise> => { const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex); if (!authIndex) throw new Error(quotaText('quota.service.error.missingResetAuthIndex')); const header: Record = { @@ -800,11 +844,14 @@ const callCodexResetCredits = async ( Originator: 'Codex Desktop', }; if (accountId) header['Chatgpt-Account-Id'] = accountId; - const payload = await requestQuotaPayload(authIndex, CODEX_RESET_CREDITS_URL, header); + const payload = await requestQuotaPayload(authIndex, CODEX_RESET_CREDITS_URL, header, 'GET', undefined, 8_000); + if (!isRecord(payload) || !['credits', 'available_count', 'availableCount', 'applicable_available_count', 'applicableAvailableCount'].some((key) => key in payload)) { + throw new Error(quotaText('quota.service.error.resetCreditsInvalid')); + } return codexResetCreditDetailsFor(payload); }; -export async function loadQuota(file: AuthFile): Promise { +async function loadQuotaSnapshot(file: AuthFile, options: QuotaLoadOptions = {}): Promise { const provider = providerForFile(file); if (!provider) { return { @@ -814,17 +861,24 @@ export async function loadQuota(file: AuthFile): Promise { }; } try { - const codexAccountId = provider === 'codex' ? await resolveCodexAccountId(file) : ''; + if (booleanValue(file.disabled) === true) throw new Error(quotaText('quota.fileDisabled')); + const codexMetadata = provider === 'codex' ? codexMetadataFor(file) : undefined; + const codexAccountId = codexMetadata?.accountId || ''; + const responseClock: { serverTimeOffsetMs?: number } = {}; const payloadPromise = provider === 'xai' - ? callXaiQuota(file) - : callUpstreamQuota(file, provider, codexAccountId); + ? callXaiQuota(file, options) + : callUpstreamQuota(file, provider, codexAccountId, responseClock); const planPromise = provider === 'claude' ? loadClaudePlan(file) : provider === 'antigravity' ? loadAntigravityPlan(file) : Promise.resolve(undefined); + let resetCreditsError: string | undefined; const resetCreditsPromise = provider === 'codex' - ? callCodexResetCredits(file, codexAccountId).catch(() => null) + ? callCodexResetCredits(file, codexAccountId).catch((error) => { + resetCreditsError = error instanceof Error ? error.message : String(error); + return null; + }) : Promise.resolve(null); const [payload, detectedPlan, resetCreditDetails] = await Promise.all([ payloadPromise, @@ -839,15 +893,24 @@ export async function loadQuota(file: AuthFile): Promise { error: quotaText('quota.service.error.unrecognized'), }; } + const resetCredits = provider === 'codex' + ? resetCreditDetails?.availableCount ?? codexResetCreditsFor(payload) + : undefined; + const usageCreditDetails = provider === 'codex' && isRecord(payload) + ? codexResetCreditDetailsFor(payload.rate_limit_reset_credits ?? payload.rateLimitResetCredits) + : {}; return { status: 'success', rows, plan: detectedPlan - ?? readString(isRecord(payload) ? payload : {}, 'plan_type', 'planType'), - resetCredits: provider === 'codex' - ? resetCreditDetails?.availableCount ?? codexResetCreditsFor(payload) - : undefined, + ?? (readString(isRecord(payload) ? payload : {}, 'plan_type', 'planType') || codexMetadata?.plan), + subscriptionActiveUntil: codexMetadata?.subscriptionActiveUntil, + resetCreditsError, + resetCreditsApplicable: usageCreditDetails.applicableAvailableCount + ?? resetCreditDetails?.applicableAvailableCount ?? resetCredits, + resetCredits, resetCreditsEarliestExpiry: resetCreditDetails?.earliestExpiry, + serverTimeOffsetMs: responseClock.serverTimeOffsetMs, fetchedAt: Date.now(), }; } catch (error) { @@ -868,10 +931,11 @@ const createRedeemRequestId = () => { }); }; -export async function consumeCodexResetCredit(file: AuthFile): Promise { +async function consumeCodexResetCreditSnapshot(file: AuthFile): Promise { if (providerForFile(file) !== 'codex') { throw new Error(quotaText('quota.service.error.codexResetOnly')); } + if (booleanValue(file.disabled) === true) throw new Error(quotaText('quota.fileDisabled')); const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex); if (!authIndex) throw new Error(quotaText('quota.service.error.missingConsumeAuthIndex')); const header = { @@ -886,5 +950,38 @@ export async function consumeCodexResetCredit(file: AuthFile): Promise>(); +const resetRequests = new Map>(); + +export function loadQuota(file: AuthFile, options: QuotaLoadOptions = {}): Promise { + const key = quotaKey(file); + const reset = resetRequests.get(key); + if (reset) return reset.catch((error): QuotaState => ({ + status: 'error', rows: [], error: error instanceof Error ? error.message : String(error), + })); + const existing = quotaRequests.get(key); + if (existing) return existing; + const request = loadQuotaSnapshot(file, options).finally(() => { + if (quotaRequests.get(key) === request) quotaRequests.delete(key); + }); + quotaRequests.set(key, request); + return request; +} + +export function consumeCodexResetCredit(file: AuthFile): Promise { + const key = quotaKey(file); + const existing = resetRequests.get(key); + if (existing) return existing; + const request = (async () => { + await quotaRequests.get(key); + return consumeCodexResetCreditSnapshot(file); + })().finally(() => { + if (resetRequests.get(key) === request) resetRequests.delete(key); + }); + resetRequests.set(key, request); + return request; } diff --git a/src/services/quotaTime.ts b/src/services/quotaTime.ts new file mode 100644 index 0000000..99af8d4 --- /dev/null +++ b/src/services/quotaTime.ts @@ -0,0 +1,76 @@ +import { useSyncExternalStore } from 'react'; +import { getCurrentLocale, translate, type AppLocale } from '../i18n'; + +export const quotaResetInstant = (value: unknown): number | undefined => { + if (typeof value !== 'number' && typeof value !== 'string') return undefined; + if (typeof value === 'string' && !value.trim()) return undefined; + const numeric = Number(value); + const ms = Number.isFinite(numeric) + ? numeric > 0 ? numeric < 1e12 ? numeric * 1000 : numeric : NaN + : Date.parse(String(value)); + return Number.isFinite(ms) && Number.isFinite(new Date(ms).getTime()) ? ms : undefined; +}; + +export const quotaResetFor = ( + record: Record, + absoluteKeys: string[], + relativeKeys: string[] = [], + nowMs = Date.now(), +): number | undefined => { + for (const key of absoluteKeys) { + const ms = quotaResetInstant(record[key]); + if (ms !== undefined) return ms; + } + for (const key of relativeKeys) { + const raw = record[key]; + if (typeof raw !== 'number' && typeof raw !== 'string') continue; + if (typeof raw === 'string' && !raw.trim()) continue; + const seconds = Number(raw); + const ms = nowMs + seconds * 1000; + if (Number.isFinite(seconds) && seconds >= 0 && Number.isFinite(new Date(ms).getTime())) return ms; + } + return undefined; +}; + +export const formatQuotaReset = ( + resetAtMs: number | undefined, + fallback?: string, + locale: AppLocale = getCurrentLocale(), + nowMs = Date.now(), +): string => { + if (resetAtMs === undefined || !Number.isFinite(resetAtMs)) return fallback || ''; + const absolute = new Intl.DateTimeFormat(locale, { + month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, + }).format(resetAtMs); + const delta = resetAtMs - nowMs; + if (delta <= 0) return `${absolute} · ${translate(locale, 'quota.resetPassed')}`; + const minutes = Math.max(1, Math.ceil(delta / 60000)); + const relative = new Intl.RelativeTimeFormat(locale, { numeric: 'always' }); + const label = minutes >= 1440 ? relative.format(Math.ceil(minutes / 1440), 'day') + : minutes >= 60 ? relative.format(Math.ceil(minutes / 60), 'hour') + : relative.format(minutes, 'minute'); + return `${absolute} · ${label}`; +}; + +// One clock shared by quota cards on both pages; no polling of upstream APIs. +let now = Date.now(); +let timer: ReturnType | undefined; +const listeners = new Set<() => void>(); +const subscribe = (listener: () => void) => { + listeners.add(listener); + if (!timer) { + now = Date.now(); + timer = setInterval(() => { + now = Date.now(); + listeners.forEach((notify) => notify()); + }, 60_000); + } + return () => { + listeners.delete(listener); + if (!listeners.size) { + clearInterval(timer); + timer = undefined; + } + }; +}; +export const useQuotaClock = () => useSyncExternalStore(subscribe, () => now, () => now); diff --git a/tests/quotaMetadata.test.ts b/tests/quotaMetadata.test.ts new file mode 100644 index 0000000..f5d0597 --- /dev/null +++ b/tests/quotaMetadata.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'bun:test'; +import { antigravityProjectFor, codexMetadataFor, isPaidXaiFile } from '../src/services/quotaMetadata'; +import { providerForFile } from '../src/services/quotaService'; + +const jwt = (payload: unknown) => `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`; + +describe('quota credential metadata', () => { + it('识别 xAI 别名,保留原有供应商别名', () => { + for (const provider of ['x_ai', 'x-ai', 'grok', 'xai', 'XAI']) { + expect(providerForFile({ provider })).toBe('xai'); + } + expect(providerForFile({ type: 'anthropic' })).toBe('claude'); + expect(providerForFile({ type: 'anti_gravity' })).toBe('antigravity'); + }); + + it('Codex 支持各层的 JWT、JSON 和对象令牌以及命名空间字段', () => { + const info = { + chatgpt_account_id: 'account-中文', chatgpt_plan_type: 'PRO', + chatgpt_subscription_active_until: '2030-01-01T00:00:00Z', + }; + for (const token of [info, JSON.stringify(info), jwt({ 'https://api.openai.com/auth': info })]) { + for (const file of [{ id_token: token }, { metadata: { idToken: token } }, { attributes: { id_token: token } }]) { + expect(codexMetadataFor(file)).toEqual({ + accountId: 'account-中文', plan: 'pro', subscriptionActiveUntil: '2030-01-01T00:00:00Z', + }); + } + } + }); + + it('保留 Codex 直接 account_id 和订阅对象,安全忽略坏令牌', () => { + expect(codexMetadataFor({ id_token: 'bad.jwt', account_id: 'direct', metadata: { subscription: { active_until: 1900000000 } } })) + .toEqual({ accountId: 'direct', plan: undefined, subscriptionActiveUntil: '1900000000' }); + expect(codexMetadataFor({ id_token: [], subscription_active_until: 0 }).subscriptionActiveUntil).toBeUndefined(); + }); + + it('Antigravity 支持 metadata、attributes 和下载文件的项目字段', () => { + for (const file of [ + { project_id: 'p' }, { metadata: { projectId: 'p' } }, + { attributes: { gemini_virtual_project: 'p' } }, + { installed: { project_id: 'p' } }, { web: { projectId: 'p' } }, + ]) expect(antigravityProjectFor(file)).toBe('p'); + expect(antigravityProjectFor({})).toBe(''); + }); + + it('单独 using_api 或 paid 前缀不能证明 xAI 是付费账户', () => { + expect(isPaidXaiFile({ using_api: true })).toBe(false); + expect(isPaidXaiFile({ prefix: 'paid' })).toBe(false); + expect(isPaidXaiFile({ usingApi: 'YES', metadata: { prefix: 'PAID' } })).toBe(true); + expect(isPaidXaiFile({ metadata: { oauth: { access_token: jwt({ 'https://x.ai/tier': 1 }) } } })).toBe(true); + expect(isPaidXaiFile({ token: jwt({ tier: 0 }) })).toBe(false); + expect(isPaidXaiFile({ token: 'bad.jwt' })).toBe(false); + }); +}); diff --git a/tests/quotaRendering.test.tsx b/tests/quotaRendering.test.tsx new file mode 100644 index 0000000..ee0f644 --- /dev/null +++ b/tests/quotaRendering.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { I18nProvider } from '../src/i18n'; +import { QuotaCard } from '../src/pages/QuotaPage'; +import type { QuotaState } from '../src/services/quotaService'; + +const render = (quota: QuotaState, provider = 'codex') => renderToStaticMarkup( + {}} onReset={() => {}} />, +); + +describe('quota card rendering', () => { + it('即使当前适用次数为零,仍显示可用重置按钮、详情错误和订阅时间', () => { + const html = render({ + status: 'success', rows: [], resetCredits: 2, resetCreditsApplicable: 0, + resetCreditsError: 'temporary failure', subscriptionActiveUntil: '2030-01-01T00:00:00Z', + }); + expect(html).toContain('重置额度'); + expect(html).toContain('当前适用:0 次'); + expect(html).toContain('temporary failure'); + expect(html).toContain('订阅到期'); + }); + + it('付费 API 健康状态不画成 0% 或 100% 的额度条', () => { + const html = render({ status: 'success', rows: [{ label: '付费 API 可用', remainingPercent: null, detail: '上游未提供剩余额度' }] }, 'xai'); + expect(html).toContain('上游未提供剩余额度'); + expect(html).not.toContain('real-quota-track'); + }); + + it('缓存中的原始重置时间提供动态提示,过期额度不伪造为已恢复', () => { + const html = render({ status: 'success', rows: [{ label: '5h', remainingPercent: 0, resetAtMs: Date.parse('2020-01-01T00:00:00Z') }] }); + expect(html).toContain('重置时间已到,请刷新确认'); + expect(html).toContain('剩余 0%'); + }); +}); diff --git a/tests/quotaRequests.test.ts b/tests/quotaRequests.test.ts new file mode 100644 index 0000000..98ab02c --- /dev/null +++ b/tests/quotaRequests.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { managementApi } from '../src/services/managementApi'; +import { consumeCodexResetCredit, loadQuota } from '../src/services/quotaService'; + +const success = (body: unknown) => ({ status_code: 200, body }); +const codexUsage = { + rate_limit: { primary_window: { used_percent: 25, limit_window_seconds: 18000 } }, + rate_limit_reset_credits: { available_count: 2, applicable_available_count: 0 }, +}; +const codexFile = { name: 'codex-test.json', provider: 'codex', auth_index: 1 }; + +type Request = { url: string; method: string; header: Record; data?: string; authIndex: string }; +let post: ReturnType; +let get: ReturnType; +let calls: Request[]; +let handler: (request: Request) => unknown | Promise; + +beforeEach(() => { + calls = []; + handler = () => { throw new Error('Unexpected API request'); }; + post = spyOn(managementApi, 'post').mockImplementation(async (path, body) => { + expect(path).toBe('/api-call'); + const request = body as unknown as Request; + calls.push(request); + return await handler(request) as never; + }); + get = spyOn(managementApi, 'get').mockImplementation(async () => { throw new Error('Unexpected download'); }); +}); +afterEach(() => { + post.mockRestore(); + get.mockRestore(); +}); + +describe('quota API compatibility', () => { + it('Codex 使用新请求头和嵌套账户信息;积分详情失败仍显示用量及 usage 回退次数', async () => { + handler = (request) => request.url.endsWith('/usage') + ? success(codexUsage) : { statusCode: 403, bodyText: '{"error":"credits forbidden"}' }; + const result = await loadQuota({ + ...codexFile, + metadata: { id_token: { chatgpt_account_id: 'account-test', plan_type: 'pro', chatgpt_subscription_active_until: '2030-01-01T00:00:00Z' } }, + }); + expect(result).toMatchObject({ status: 'success', plan: 'pro', resetCredits: 2, resetCreditsApplicable: 0, resetCreditsError: 'credits forbidden' }); + expect(result.subscriptionActiveUntil).toBe('2030-01-01T00:00:00Z'); + expect(calls).toHaveLength(2); + for (const call of calls) { + expect(call.header['Chatgpt-Account-Id']).toBe('account-test'); + expect(call.header['User-Agent']).toContain('codex-tui/0.149.1'); + expect(call.authIndex).toBe('1'); + expect(call.header.Authorization).toBe('Bearer $TOKEN$'); + } + const creditCallIndex = calls.findIndex((call) => call.url.endsWith('/rate-limit-reset-credits')); + expect(post.mock.calls[creditCallIndex][2]).toEqual({ timeoutMs: 8000 }); + }); + + it('Codex 保留零次数,usage 的适用次数优先于详情,并优先使用用量接口套餐', async () => { + handler = (request) => request.url.endsWith('/usage') + ? success({ ...codexUsage, plan_type: 'team' }) + : success({ availableCount: 0, applicableAvailableCount: 3, credits: [] }); + expect(await loadQuota({ ...codexFile, plan_type: 'pro' })).toMatchObject({ + status: 'success', plan: 'team', resetCredits: 0, resetCreditsApplicable: 0, + }); + }); + + it('Codex 详情空列表不能覆盖 usage 报告的可用次数', async () => { + handler = (request) => success(request.url.endsWith('/usage') ? codexUsage : { credits: [] }); + expect(await loadQuota(codexFile)).toMatchObject({ status: 'success', resetCredits: 2 }); + }); + + it('Codex 详情返回坏数据时报告非致命错误而不是吞掉失败', async () => { + handler = (request) => success(request.url.endsWith('/usage') ? codexUsage : { unexpected: true }); + const result = await loadQuota(codexFile); + expect(result.status).toBe('success'); + expect(result.resetCredits).toBe(2); + expect(result.resetCreditsError).toContain('无法识别'); + }); + + it('Claude profile 查询失败不影响现代 Fable 额度', async () => { + handler = (request) => request.url.endsWith('/profile') + ? { status_code: 500 } + : success({ limits: [{ kind: 'weekly_scoped', percent: 40, scope: { model: { display_name: 'Fable 5' } } }] }); + expect(await loadQuota({ name: 'claude.json', provider: 'claude', auth_index: 'c' })).toMatchObject({ + status: 'success', rows: [{ label: '7 天 Fable 窗口', remainingPercent: 60 }], + }); + }); + + it('Antigravity 使用元数据项目、不下载凭据,空响应后回退并读取服务端时间', async () => { + let quotaCalls = 0; + const serverTime = 'Tue, 01 Jan 2030 00:00:00 GMT'; + handler = (request) => { + if (request.url.endsWith(':loadCodeAssist')) return success({ paidTier: { id: 'g1-ultra-lite-tier' } }); + quotaCalls += 1; + return quotaCalls === 1 ? success({ groups: [] }) : { + ...success({ groups: [{ displayName: 'Gemini', buckets: [{ remainingFraction: 0.8, resetTime: '2030-01-01T01:00:00Z' }] }] }), + header: { Date: [serverTime] }, + }; + }; + const result = await loadQuota({ name: 'anti.json', provider: 'antigravity', auth_index: 'a', attributes: { gemini_virtual_project: 'project-test' } }); + expect(result).toMatchObject({ status: 'success', plan: 'Ultra Lite' }); + expect(get).not.toHaveBeenCalled(); + const requests = calls.filter((request) => request.url.endsWith(':retrieveUserQuotaSummary')); + expect(requests).toHaveLength(2); + expect(requests[1].url).toContain('sandbox.googleapis.com'); + requests.forEach((request) => expect(JSON.parse(request.data!)).toEqual({ project: 'project-test' })); + expect(Math.abs((result.serverTimeOffsetMs ?? 0) - (Date.parse(serverTime) - Date.now()))).toBeLessThan(1000); + }); + + it('Antigravity 项目缺失时从下载的 JSON 中提取 installed 项目', async () => { + get.mockImplementation(async () => JSON.stringify({ installed: { project_id: 'downloaded-project' } })); + handler = (request) => success(request.url.endsWith(':loadCodeAssist') ? {} : { + groups: [{ buckets: [{ remainingFraction: 1 }] }], + }); + expect((await loadQuota({ name: 'anti-download.json', provider: 'antigravity', auth_index: 'a' })).status).toBe('success'); + expect(get).toHaveBeenCalledWith('/auth-files/download', { name: 'anti-download.json' }); + expect(calls.find((request) => request.url.endsWith(':retrieveUserQuotaSummary'))?.data).toBe('{"project":"downloaded-project"}'); + }); + + it('缺少 auth-index 或禁用的凭据不发起请求', async () => { + expect((await loadQuota({ provider: 'kimi' })).status).toBe('error'); + expect((await loadQuota({ ...codexFile, disabled: 'true' })).status).toBe('error'); + await expect(consumeCodexResetCredit({ ...codexFile, disabled: true })).rejects.toThrow(); + expect(post).not.toHaveBeenCalled(); + }); +}); + +describe('xAI billing and paid API fallback', () => { + const file = { name: 'xai.json', provider: 'x-ai', auth_index: 'x' }; + + it('单个账单接口失败仍展示另一接口的数据,不触发收费探测', async () => { + handler = (request) => request.url.includes('format=credits') + ? { status_code: 403, body: 'weekly denied' } + : success({ config: { monthlyLimit: 1000, used: 200 } }); + const result = await loadQuota({ ...file, metadata: { user: { id: 'u' } } }); + expect(result.status).toBe('success'); + expect(result.rows[0].remainingPercent).toBe(80); + expect(calls).toHaveLength(2); + calls.forEach((request) => expect(request.header['x-userid']).toBe('u')); + }); + + it('识别付费账户后先确认,默认不发送计费请求', async () => { + const result = await loadQuota({ ...file, using_api: true, prefix: 'paid' }); + expect(result.status).toBe('error'); + expect(result.error).toContain('需要确认'); + expect(post).not.toHaveBeenCalled(); + }); + + it('确认后执行极短对话探测,profile 失败不影响结果,也不伪造剩余额度', async () => { + handler = (request) => request.url.endsWith('/me') ? { status_code: 403 } : success({ choices: [] }); + const result = await loadQuota({ ...file, using_api: true, prefix: 'paid' }, { confirmXaiPaidProbe: () => true }); + expect(result).toMatchObject({ status: 'success', plan: 'Paid', rows: [{ remainingPercent: null }] }); + expect(calls).toHaveLength(2); + expect(calls.every((request) => request.url.startsWith('https://api.x.ai/'))).toBe(true); + expect(JSON.parse(calls.find((request) => request.method === 'POST')!.data!)).toEqual({ + model: 'grok-4.5', messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, stream: false, + }); + post.mock.calls.forEach((call: unknown[]) => expect(call[2]).toEqual({ timeoutMs: 15000 })); + }); + + it('两个账单均无数据时可以确认回退;两种模式均失败时保留原账单错误', async () => { + handler = (request) => request.url.includes('cli-chat-proxy') ? { status_code: 403, body: 'billing denied' } + : { status_code: 429, body: 'paid denied' }; + const result = await loadQuota(file, { confirmXaiPaidProbe: () => true }); + expect(result).toMatchObject({ status: 'error', error: 'billing denied' }); + expect(calls).toHaveLength(4); + }); + + it('不允许仅凭 profile 成功把对话失败的付费账户标记为可用', async () => { + handler = (request) => request.url.endsWith('/me') ? success({ user_id: 'u' }) : { status_code: 429, body: 'chat denied' }; + expect(await loadQuota({ ...file, using_api: true, prefix: 'paid' }, { confirmXaiPaidProbe: () => true })) + .toMatchObject({ status: 'error', error: 'chat denied' }); + }); +}); + +describe('quota request synchronization', () => { + it('跨页面重复刷新共用同一个请求', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + handler = async (request) => { + await gate; + return success(request.url.endsWith('/usage') ? codexUsage : { available_count: 2 }); + }; + const first = loadQuota(codexFile); + const second = loadQuota(codexFile); + expect(second).toBe(first); + release(); + await Promise.all([first, second]); + expect(calls).toHaveLength(2); + }); + + it('重置期间刷新等待重置结果,连续点击只消费一次且随后重查用量', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + handler = async (request) => { + if (request.url.endsWith('/consume')) { + await gate; + return success({}); + } + return success(request.url.endsWith('/usage') ? codexUsage : { available_count: 1 }); + }; + const reset = consumeCodexResetCredit(codexFile); + const secondReset = consumeCodexResetCredit(codexFile); + const refresh = loadQuota(codexFile); + expect(secondReset).toBe(reset); + release(); + expect((await reset).status).toBe('success'); + expect(await refresh).toEqual(await reset); + const consumes = calls.filter((request) => request.url.endsWith('/consume')); + expect(consumes).toHaveLength(1); + expect(JSON.parse(consumes[0].data!).redeem_request_id).toMatch(/^[0-9a-f-]{36}$/); + expect(calls.map((request) => request.method)).toEqual(['POST', 'GET', 'GET']); + }); + + it('已有刷新结束后才消费重置积分,避免把旧请求结果当成重置后的额度', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + handler = async (request) => { + await gate; + return success(request.url.endsWith('/usage') ? codexUsage : { available_count: 1 }); + }; + const refresh = loadQuota(codexFile); + const reset = consumeCodexResetCredit(codexFile); + await Promise.resolve(); + expect(calls.some((request) => request.url.endsWith('/consume'))).toBe(false); + release(); + await Promise.all([refresh, reset]); + expect(calls.map((request) => request.method)).toEqual(['GET', 'GET', 'POST', 'GET', 'GET']); + }); + + it('重置失败后释放锁,允许重新刷新', async () => { + handler = (request) => request.url.endsWith('/consume') + ? { status_code: 409, body: 'reset denied' } + : success(request.url.endsWith('/usage') ? codexUsage : { available_count: 1 }); + const reset = consumeCodexResetCredit(codexFile); + const refresh = loadQuota(codexFile); + await expect(reset).rejects.toThrow('reset denied'); + expect(await refresh).toMatchObject({ status: 'error', error: 'reset denied' }); + expect((await loadQuota(codexFile)).status).toBe('success'); + }); +}); diff --git a/tests/quotaService.test.ts b/tests/quotaService.test.ts index 4593e22..524dd10 100644 --- a/tests/quotaService.test.ts +++ b/tests/quotaService.test.ts @@ -56,7 +56,7 @@ describe('quotaRowsFor', () => { ]); }); - it('Team 次级窗口缺少时长时按月限额处理', () => { + it('Team 次级窗口缺少时长时与新版一致按周窗口回退,不臆测月限额', () => { const rows = quotaRowsFor('codex', { plan_type: 'team', rate_limit: { @@ -65,7 +65,20 @@ describe('quotaRowsFor', () => { }, }); - expect(rows.map((row) => row.label)).toEqual(['5 小时限额', '月限额']); + expect(rows.map((row) => row.label)).toEqual(['5 小时限额', '周限额']); + }); + + it('支持反序窗口且只在有重置时间时推断已耗尽额度', () => { + const rows = quotaRowsFor('codex', { + rateLimit: { + allowed: false, + primaryWindow: { limitWindowSeconds: 604800 }, + secondaryWindow: { limitWindowSeconds: 18000, resetAfterSeconds: 3600 }, + }, + }); + expect(rows.map((row) => row.label)).toEqual(['5 小时限额', '周限额']); + expect(rows.map((row) => row.remainingPercent)).toEqual([0, null]); + expect(rows[0].resetAtMs).toBeGreaterThan(Date.now()); }); it('读取 Codex 可用重置额度', () => { @@ -111,6 +124,27 @@ describe('quotaRowsFor', () => { }); }); + it('重置次数支持 applicable 字段、空列表,不把过期积分算入推断次数', () => { + expect(codexResetCreditDetailsFor({ credits: [] })).toEqual({ availableCount: undefined, earliestExpiry: undefined }); + expect(codexResetCreditDetailsFor({ + applicableAvailableCount: '0', + credits: [ + { resetType: 'codex_rate_limits', status: 'available', expiresAt: '2030-01-01T00:00:00Z' }, + { resetType: 'codex_rate_limits', status: 'available', expiresAt: '2020-01-01T00:00:00Z' }, + { resetType: 'codex_rate_limits', status: 'available', expiresAt: 'invalid' }, + { resetType: 'other', status: 'available', expiresAt: '2030-01-01T00:00:00Z' }, + ], + }, Date.parse('2026-01-01T00:00:00Z'))).toEqual({ + availableCount: 1, applicableAvailableCount: 0, earliestExpiry: '2030-01-01T00:00:00Z', + }); + }); + + it('不会把数组、对象和布尔值当作百分比', () => { + for (const used of [[], [25], {}, false, '', ' ', NaN, Infinity]) { + expect(quotaRowsFor('codex', { rate_limit: { primary_window: { used_percent: used } } })[0].remainingPercent).toBeNull(); + } + }); + it('不会把小于 1 的上游百分比错误放大 100 倍', () => { const codex = quotaRowsFor('codex', { rate_limit: { primary_window: { used_percent: 0.63 } }, @@ -123,6 +157,31 @@ describe('quotaRowsFor', () => { expect(claude[0].remainingPercent).toBeCloseTo(99.56); }); + it('Claude 优先活动的现代 Fable 窗口,并去除旧字段重复项', () => { + const limit = (percent: unknown, active: boolean, name = 'Fable') => ({ + kind: 'weekly_scoped', percent, is_active: active, + scope: { model: { display_name: name } }, resets_at: '2030-01-01T00:00:00Z', + }); + const rows = quotaRowsFor('claude', { + iguana_necktie: { utilization: 41 }, + five_hour: { utilization: 10 }, + limits: [null, limit(null, true), limit(12, false, 'Fable 5'), limit(64, true), limit(99, true, 'Sonnet')], + }); + expect(rows.map((row) => [row.label, row.remainingPercent])).toEqual([ + ['5 小时窗口', 90], ['7 天 Fable 窗口', 36], + ]); + expect(rows[1].resetAtMs).toBe(Date.parse('2030-01-01T00:00:00Z')); + }); + + it('Claude 无有效现代 Fable 数据时兼容旧字段', () => { + const rows = quotaRowsFor('claude', { + iguana_necktie: { utilization: 41 }, + limits: [{ kind: 'weekly_scoped', percent: null, scope: { model: { display_name: 'Fable' } } }], + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ label: '7 天 Fable 窗口', remainingPercent: 59 }); + }); + it('显示 Claude 已启用的额外用量', () => { const rows = quotaRowsFor('claude', { five_hour: { utilization: 20, resets_at: '2027-01-01T00:00:00Z' }, @@ -159,6 +218,33 @@ describe('quotaRowsFor', () => { expect(antigravity[0].remainingPercent).toBe(3); }); + it('Kimi 短期窗口排在周汇总前,识别 protobuf 时间单位且保留小数', () => { + const rows = quotaRowsFor('kimi', { + usage: { used: 99.5, limit: 100 }, + limits: [ + { window: { duration: 5, timeUnit: 'TIME_UNIT_HOUR' }, detail: { limit: 100, remaining: 80, reset_in: 3600 } }, + { window: { duration: 1, time_unit: 'TIME_UNIT_WEEK' }, detail: { limit: 100, used: 20 } }, + { window: { duration: 300, timeUnit: 'TIME_UNIT_MINUTE' }, detail: { limit: 100, used: 0 } }, + { window: { duration: 90, timeUnit: 'TIME_UNIT_SECOND' }, detail: { limit: 100, used: 0 } }, + ], + }); + expect(rows.map((row) => row.label)).toEqual(['5 小时窗口', '7 天窗口', '5 小时窗口', '90 秒窗口', '每周额度']); + expect(rows.at(-1)?.remainingPercent).toBeCloseTo(0.5); + expect(rows[0].remainingPercent).toBe(80); + expect(rows[0].resetAtMs).toBeGreaterThan(Date.now()); + }); + + it('Antigravity 把 5h 排在周窗口前,兼容百分数字符串且不修改响应', () => { + const buckets = [{ window: 'weekly', remainingFraction: '40%' }, { window: 'five_hour', remaining_fraction: 0.8 }]; + const rows = quotaRowsFor('antigravity', { groups: [{ displayName: 'Gemini', buckets }] }); + expect(rows.map((row) => row.remainingPercent)).toEqual([80, 40]); + expect(buckets[0].window).toBe('weekly'); + }); + + it('Antigravity 兼容 body 包装的额度响应', () => { + expect(quotaRowsFor('antigravity', { body: JSON.stringify({ groups: [{ buckets: [{ remainingFraction: 0.5 }] }] }) })[0].remainingPercent).toBe(50); + }); + it('区分 Antigravity 同一分组中的不同窗口', () => { const rows = quotaRowsFor('antigravity', { groups: [{ @@ -176,6 +262,22 @@ describe('quotaRowsFor', () => { ]); }); + it('xAI 仅返回产品用量时仍能解析,付费探测不伪造百分比', () => { + expect(quotaRowsFor('xai', { config: { productUsage: [{ product: 'grok', usagePercent: 20 }] } })[1]) + .toMatchObject({ label: 'grok', remainingPercent: 80 }); + expect(quotaRowsFor('xai', { mode: 'paid-health' })[0]) + .toMatchObject({ label: '付费 API 可用', remainingPercent: null }); + }); + + it('xAI 不从月账单借用周窗口的重置时间', () => { + const rows = quotaRowsFor('xai', { + weekly: { config: { creditUsagePercent: 25 } }, + monthly: { config: { monthlyLimit: 1000, used: 500, billingPeriodEnd: '2030-01-01T00:00:00Z' } }, + }); + expect(rows[0].resetAtMs).toBeUndefined(); + expect(rows[1].resetAtMs).toBe(Date.parse('2030-01-01T00:00:00Z')); + }); + it('合并 xAI 每周、月度和按量付费额度', () => { const rows = quotaRowsFor('xai', { weekly: { diff --git a/tests/quotaTime.test.ts b/tests/quotaTime.test.ts new file mode 100644 index 0000000..cf3aac3 --- /dev/null +++ b/tests/quotaTime.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'bun:test'; +import { formatQuotaReset, quotaResetFor, quotaResetInstant } from '../src/services/quotaTime'; +import { formatQuotaTimestamp } from '../src/services/quotaService'; + +const resetMs = Date.parse('2030-01-01T00:00:00Z'); + +describe('quota reset instants', () => { + it('兼容秒、毫秒、数字字符串与高精度 ISO 时间', () => { + for (const input of [resetMs, resetMs / 1000, String(resetMs), String(resetMs / 1000), '2030-01-01T00:00:00.000000000Z']) { + expect(quotaResetInstant(input)).toBe(resetMs); + } + for (const input of [null, undefined, '', ' ', 0, -1, false, [], {}, NaN, Infinity, 'invalid']) { + expect(quotaResetInstant(input)).toBeUndefined(); + } + expect(formatQuotaTimestamp(String(resetMs / 1000), 'en')).not.toBe('—'); + }); + + it('跳过坏绝对时间后使用有效别名或相对秒数,仅在获取时计算倒计时', () => { + expect(quotaResetFor({ reset_at: 'bad', resetAt: resetMs }, ['reset_at', 'resetAt'])).toBe(resetMs); + expect(quotaResetFor({ ttl: '3600' }, ['reset_at'], ['ttl'], resetMs)).toBe(resetMs + 3600000); + expect(quotaResetFor({ ttl: 0 }, [], ['ttl'], resetMs)).toBe(resetMs); + expect(quotaResetFor({ ttl: false }, [], ['ttl'], resetMs)).toBeUndefined(); + }); + + it('同一个重置时间显示随时钟更新,过期后提示刷新而不是伪造已恢复额度', () => { + expect(formatQuotaReset(resetMs, undefined, 'zh-CN', resetMs - 60000)).toContain('1分钟后'); + expect(formatQuotaReset(resetMs, undefined, 'en', resetMs - 60000)).toContain('in 1 minute'); + expect(formatQuotaReset(resetMs, undefined, 'zh-CN', resetMs)).toContain('重置时间已到,请刷新确认'); + expect(formatQuotaReset(undefined, 'legacy', 'en', resetMs)).toBe('legacy'); + }); +});