// DOM-free view-model + renderer for per-account ChatGPT Subscription usage. // // The backend (/api/chatgpt-subscription/accounts/{auth_id}/usage) returns a // normalized, credential-free payload. This module turns it into something the // Settings "Added Models" card can render, defensively: unknown buckets are // kept, missing windows are tolerated and nothing (reset times, percentages) // is ever invented. function _num(value) { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string' && value.trim() !== '') { const n = Number(value); return Number.isFinite(n) ? n : null; } return null; } function _clampPercent(value) { const n = _num(value); if (n === null) return null; return Math.max(0, Math.min(100, n)); } function _defaultEsc(text) { return String(text == null ? '' : text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } export function formatDuration(totalSeconds) { const secs = _num(totalSeconds); if (secs === null) return ''; if (secs <= 0) return 'now'; const days = Math.floor(secs / 86400); const hours = Math.floor((secs % 86400) / 3600); const minutes = Math.floor((secs % 3600) / 60); if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`; if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; if (minutes > 0) return `${minutes}m`; return '<1m'; } export function formatResetIn(resetsAt, nowSeconds) { const at = _num(resetsAt); if (at === null || at <= 0) return ''; const now = _num(nowSeconds); if (now === null) return ''; const delta = at - now; if (delta <= 0) return 'resets now'; return `resets in ${formatDuration(delta)}`; } export function formatPercent(value) { const n = _clampPercent(value); if (n === null) return ''; const rounded = Math.round(n); return `${rounded}%`; } export function planDisplayName(planType) { if (typeof planType !== 'string' || !planType.trim()) return ''; const key = planType.trim().toLowerCase(); const known = { free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro', prolite: 'Pro Lite', team: 'Team', business: 'Business', enterprise: 'Enterprise', edu: 'Edu', education: 'Education', guest: 'Guest', }; if (known[key]) return known[key]; return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } function _windowViewModel(win, nowSeconds) { if (!win || typeof win !== 'object') return null; const used = _clampPercent(win.used_percent); const remaining = used === null ? _clampPercent(win.remaining_percent) : Math.round((100 - used) * 100) / 100; const windowMinutes = _num(win.window_minutes); const resetsAt = _num(win.resets_at); return { kind: typeof win.kind === 'string' ? win.kind : '', name: typeof win.name === 'string' && win.name ? win.name : 'LIMIT', usedPercent: used, remainingPercent: remaining, usedLabel: used === null ? 'usage unknown' : `${formatPercent(used)} used`, remainingLabel: remaining === null ? '' : `${formatPercent(remaining)} remaining`, windowMinutes, resetsAt, resetLabel: formatResetIn(resetsAt, nowSeconds), exhausted: used !== null && used >= 100, }; } /** * Build a render-ready view model from the backend usage payload. * * `payload` is the JSON body of the usage route. `nowSeconds` is injected so * reset countdowns are deterministic in tests. */ export function buildUsageViewModel(payload, nowSeconds = Math.floor(Date.now() / 1000)) { const account = (payload && typeof payload.account === 'object' && payload.account) || {}; const base = { authId: typeof account.auth_id === 'string' ? account.auth_id : '', label: typeof account.label === 'string' ? account.label : '', name: typeof account.name === 'string' ? account.name : '', }; if (!payload || typeof payload !== 'object' || payload.available !== true || !payload.usage || typeof payload.usage !== 'object') { const reason = payload && typeof payload.reason === 'string' ? payload.reason : 'unavailable'; const messages = { reauth: 'Usage unavailable — account may need reconnecting', rate_limited: 'Usage temporarily unavailable (rate limited)', timeout: 'Usage unavailable (timed out)', network: 'Usage unavailable (network)', malformed: 'Usage unavailable (unexpected response)', upstream: 'Usage unavailable', }; return { ...base, available: false, reason, reconnectSuggested: !!(payload && payload.reconnect_suggested), message: messages[reason] || 'Usage unavailable', plan: '', limits: [], }; } const usage = payload.usage; const limits = []; const rawLimits = Array.isArray(usage.limits) ? usage.limits : []; rawLimits.forEach((limit) => { if (!limit || typeof limit !== 'object') return; const windows = (Array.isArray(limit.windows) ? limit.windows : []) .map((w) => _windowViewModel(w, nowSeconds)) .filter(Boolean); const limitId = typeof limit.limit_id === 'string' ? limit.limit_id : ''; const limitName = typeof limit.limit_name === 'string' ? limit.limit_name : ''; limits.push({ limitId, title: limitId === 'codex' ? '' : (limitName || limitId || 'Additional limit'), modelSlug: typeof limit.normal_model_slug === 'string' ? limit.normal_model_slug : '', limitReached: limit.limit_reached === true, windows, }); }); return { ...base, available: true, reason: '', reconnectSuggested: false, message: '', plan: planDisplayName(usage.plan_type), ordinaryUsageAllowed: typeof usage.ordinary_usage_allowed === 'boolean' ? usage.ordinary_usage_allowed : null, rateLimitReachedType: typeof usage.rate_limit_reached_type === 'string' ? usage.rate_limit_reached_type : '', cached: payload.usage.cached === true, fetchedAt: _num(usage.fetched_at), limits, hasWindows: limits.some((l) => l.windows.length > 0), }; } function _barHtml(win, esc) { const used = win.usedPercent === null ? 0 : win.usedPercent; const tone = win.usedPercent === null ? 'unknown' : (used >= 90 ? 'critical' : (used >= 70 ? 'warn' : 'ok')); return ( `
` + `
` + `${esc(win.name)}` + `${esc(win.remainingLabel || win.usedLabel)}` + `
` + `
` + `` + `
` + `
` + `${esc(win.usedLabel)}` + (win.resetLabel ? `${esc(win.resetLabel)}` : '') + `
` + `
` ); } /** * Render the compact usage card body for one account. `esc` is the host * page's HTML escaper (defaults to a local one). Buttons carry data * attributes with the exact auth/endpoint ids so handlers target only this * account. */ export function renderUsageCardHtml(viewModel, options = {}) { const esc = typeof options.esc === 'function' ? options.esc : _defaultEsc; const vm = viewModel || {}; const authId = esc(vm.authId || ''); const endpointId = esc(options.endpointId || ''); const includeReconnect = options.includeReconnect !== false; const buttons = `
` + `` + (includeReconnect ? `` : '') + `
`; if (!vm.available) { return ( `
` + `
${esc(vm.message || 'Usage unavailable')}
` + buttons + `
` ); } const plan = vm.plan ? `${esc(vm.plan)}` : ''; const limitsHtml = (vm.limits || []).map((limit) => { const title = limit.title ? `
${esc(limit.title)}${limit.modelSlug ? ` ${esc(limit.modelSlug)}` : ''}
` : ''; return `
${title}${limit.windows.length ? limit.windows.map((w) => _barHtml(w, esc)).join('') : '
No rate-limit windows reported
'}
`; }).join(''); const blocked = vm.ordinaryUsageAllowed === false ? `
Usage currently blocked${vm.rateLimitReachedType ? ` (${esc(vm.rateLimitReachedType.replace(/_/g, ' '))})` : ''}
` : ''; return ( `
` + (plan ? `
${plan}
` : '') + blocked + limitsHtml + buttons + `
` ); } /** True when an endpoint row from /api/model-endpoints is a ChatGPT account. */ export function isChatgptSubscriptionEndpoint(ep) { return !!(ep && ep.provider === 'chatgpt-subscription' && ep.provider_auth_id); } /** Owner-visible account title, e.g. "ChatGPT · codex00". */ export function accountTitle(ep) { if (!ep) return ''; if (ep.account_label) return `ChatGPT · ${ep.account_label}`; return ep.name || 'ChatGPT Subscription'; } export default { buildUsageViewModel, renderUsageCardHtml, formatResetIn, formatDuration, formatPercent, planDisplayName, isChatgptSubscriptionEndpoint, accountTitle, };