import { getModelsByProviderId } from "open-sse/config/providerModels.js"; // ─── Constants ─────────────────────────────────────────────────────────────── export const QUOTA_CACHE_KEY = "quotaCacheData"; export const REFRESH_INTERVAL_MS = 60000; // Claude usage/quota endpoint rate-limits; poll it less often than other providers export const CLAUDE_REFRESH_INTERVAL_MS = 180000; export const DEPLETED_QUOTA_THRESHOLD = 5; export const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh"; export const CONNECTIONS_PAGE_SIZE = 20; export const ACCOUNT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; export const ACCOUNT_PAGE_SIZE_MAX = 500; export const ACCOUNT_FILTER_OPTIONS = [ { value: "all", label: "All accounts" }, { value: "active", label: "Active" }, { value: "inactive", label: "Turned off" }, ]; export const QUOTA_SORT_OPTIONS = [ { value: "default", label: "Default quota order" }, { value: "remaining-asc", label: "% quota: low to high" }, { value: "remaining-desc", label: "% quota: high to low" }, ]; // ─── Pure helpers ───────────────────────────────────────────────────────────── export function getConnectionLabel(connection) { return connection.name?.trim() || connection.email?.trim() || connection.displayName?.trim() || null; } export function getConnectionQuotaRemaining(connection, quotaData) { const quota = quotaData[connection.id]?.quotas?.[0]; if (!quota) return Number.POSITIVE_INFINITY; if (typeof quota.remaining === "number") return quota.remaining; return Number.POSITIVE_INFINITY; } export function sortVisibleConnections( connections, quotaData, expiringFirst, providerFilter, quotaSortMode, ) { if (providerFilter === "codex" && quotaSortMode !== "default") { return [...connections].sort((a, b) => { const remainingA = getConnectionQuotaRemaining(a, quotaData); const remainingB = getConnectionQuotaRemaining(b, quotaData); const remainingDiff = quotaSortMode === "remaining-asc" ? remainingA - remainingB : remainingB - remainingA; if (remainingDiff !== 0) return remainingDiff; return (getConnectionLabel(a) || "").localeCompare( getConnectionLabel(b) || "", ); }); } if (!expiringFirst) return connections; const getEarliestResetTime = (connection) => { const resetTimes = (quotaData[connection.id]?.quotas || []) .map((quota) => quota.resetAt ? new Date(quota.resetAt).getTime() : Number.POSITIVE_INFINITY, ) .filter((time) => Number.isFinite(time)); return resetTimes.length > 0 ? Math.min(...resetTimes) : Number.POSITIVE_INFINITY; }; return [...connections].sort((a, b) => { const expiryDiff = getEarliestResetTime(a) - getEarliestResetTime(b); if (expiryDiff !== 0) return expiryDiff; return ( (a.provider || "").localeCompare(b.provider || "") || (getConnectionLabel(a) || "").localeCompare(getConnectionLabel(b) || "") ); }); } export function buildLoadingState(connections) { const nextLoadingState = {}; connections.forEach((connection) => { nextLoadingState[connection.id] = true; }); return nextLoadingState; } export function filterQuotaStateByConnections(state, connections) { const visibleIds = new Set(connections.map((connection) => connection.id)); return Object.fromEntries( Object.entries(state).filter(([id]) => visibleIds.has(id)), ); } export function getConnectionsPageRange(pagination) { if (!pagination.total) { return { start: 0, end: 0 }; } const start = (pagination.page - 1) * pagination.pageSize + 1; const end = Math.min(pagination.page * pagination.pageSize, pagination.total); return { start, end }; } export function getConnectionsEmptyMessage(totals, providerFilter, accountFilter) { if (!totals.eligibleConnections) { return { icon: "cloud_off", title: "No Providers Connected", description: "Connect to providers with OAuth to track your API quota limits and usage.", }; } if (!totals.providerFilteredConnections) { return { icon: "filter_alt_off", title: "No Accounts Match Current Filters", description: providerFilter === "all" ? "Try changing the account status filter to see more quota trackers." : `No ${accountFilter === "inactive" ? "turned off" : accountFilter === "active" ? "active" : "matching"} accounts found for ${providerFilter}.`, }; } return { icon: "filter_alt_off", title: "No Accounts On This Page", description: "Try moving to another page or refreshing the current filters.", }; } export function sortRequestFromExpiringFirst(expiringFirst) { return expiringFirst ? "expiring" : "priority"; } export function getPageSizeLabel(pageSize, isCustomPageSize) { return isCustomPageSize ? `Custom: ${pageSize} / page` : `${pageSize} / page`; } export function getConnectionsPaginationSummary(pagination) { const { start, end } = getConnectionsPageRange(pagination); return `Showing ${start}-${end} of ${pagination.total}`; } export function getSafePagination(pagination, fallbackPageSize) { return ( pagination || { page: 1, pageSize: fallbackPageSize, total: 0, totalPages: 1, } ); } export function getSafeTotals(totals, fallbackTotal = 0) { return ( totals || { eligibleConnections: fallbackTotal, providerFilteredConnections: fallbackTotal, } ); } export function shouldResetPage(previousValue, nextValue) { return previousValue !== nextValue; } export function getPaginationPageValue(dataPagination, fallbackPage) { return dataPagination?.page || fallbackPage; } export function getProviderOptions(dataProviderOptions) { return dataProviderOptions || []; } export async function reconcileConnectionsPage(fetchConnections, targetPage) { return await fetchConnections(targetPage); } export function getQuotaCache() { if (typeof window === "undefined") return {}; try { const cached = window.localStorage.getItem(QUOTA_CACHE_KEY); return cached ? JSON.parse(cached) : {}; } catch (error) { console.error("Error reading quota cache:", error); return {}; } } export function setQuotaCache(connectionId, quotaEntry) { if (typeof window === "undefined") return; try { const cache = getQuotaCache(); cache[connectionId] = { ...quotaEntry, cachedAt: new Date().toISOString(), }; window.localStorage.setItem(QUOTA_CACHE_KEY, JSON.stringify(cache)); } catch (error) { console.error("Error writing quota cache:", error); } } /** * Format ISO date string to countdown format (inspired by vscode-antigravity-cockpit) * @param {string|Date} date - ISO date string or Date object * @returns {string} Formatted countdown (e.g., "2d 5h 30m", "4h 40m", "15m") or "-" */ export function formatResetTime(date) { if (!date) return "-"; try { const resetDate = typeof date === "string" ? new Date(date) : date; const now = new Date(); const diffMs = resetDate - now; if (diffMs <= 0) return "-"; const totalMinutes = Math.ceil(diffMs / (1000 * 60)); // < 60 minutes: show only minutes if (totalMinutes < 60) { return `${totalMinutes}m`; } const totalHours = Math.floor(totalMinutes / 60); const remainingMinutes = totalMinutes % 60; // < 24 hours: show hours and minutes if (totalHours < 24) { return `${totalHours}h ${remainingMinutes}m`; } // >= 24 hours: show days, hours, and minutes const days = Math.floor(totalHours / 24); const remainingHours = totalHours % 24; return `${days}d ${remainingHours}h ${remainingMinutes}m`; } catch (error) { return "-"; } } /** * Get Tailwind color class based on percentage * @param {number} percentage - Remaining percentage (0-100) * @returns {string} Color name: "green" | "yellow" | "red" */ export function getStatusColor(percentage) { if (percentage > 70) return "green"; if (percentage >= 30) return "yellow"; return "red"; // 0-29% including 0% (out of quota) - show red } /** * Get status emoji based on percentage * @param {number} percentage - Remaining percentage (0-100) * @returns {string} Emoji: "🟢" | "🟡" | "🔴" */ export function getStatusEmoji(percentage) { if (percentage > 70) return "🟢"; if (percentage >= 30) return "🟡"; return "🔴"; // 0-29% including 0% (out of quota) - show red } /** * Calculate remaining percentage * @param {number} used - Used amount * @param {number} total - Total amount * @returns {number} Remaining percentage (0-100) */ export function calculatePercentage(used, total) { if (!total || total === 0) return 0; if (!used || used < 0) return 100; if (used >= total) return 0; return Math.round(((total - used) / total) * 100); } /** * Get remaining percentage from a normalized quota row * @param {Object} quota - Normalized quota object * @returns {number} Remaining percentage (0-100) */ export function getRemainingPercentage(quota) { if (quota?.remaining !== undefined) { return Math.max(0, Math.round(quota.remaining)); } if (quota?.remainingPercentage !== undefined) { return Math.round(quota.remainingPercentage); } return calculatePercentage(quota?.used, quota?.total); } /** * Parse provider-specific quota structures into normalized array * @param {string} provider - Provider name (github, antigravity, codex, kiro, claude) * @param {Object} data - Raw quota data from provider * @returns {Array} Normalized quota objects with { name, used, total, resetAt } */ export function parseQuotaData(provider, data) { if (!data || typeof data !== "object") return []; const normalizedQuotas = []; try { switch (provider.toLowerCase()) { case "github": if (data.quotas) { Object.entries(data.quotas).forEach(([name, quota]) => { normalizedQuotas.push({ name, used: quota.used || 0, total: quota.total || 0, resetAt: quota.resetAt || null, }); }); } break; case "antigravity": if (data.quotas) { Object.entries(data.quotas).forEach(([modelKey, quota]) => { normalizedQuotas.push({ name: quota.displayName || modelKey, modelKey: modelKey, // Keep modelKey for sorting used: quota.used || 0, total: quota.total || 0, resetAt: quota.resetAt || null, remainingPercentage: quota.remainingPercentage, }); }); } break; case "codex": if (data.quotas) { Object.entries(data.quotas).forEach(([quotaType, quota]) => { normalizedQuotas.push({ name: quotaType, used: quota.used || 0, total: quota.total || 0, remaining: quota.remaining, resetAt: quota.resetAt || null, }); }); } break; case "kiro": if (data.quotas) { Object.entries(data.quotas).forEach(([quotaType, quota]) => { normalizedQuotas.push({ name: quotaType, used: quota.used || 0, total: quota.total || 0, resetAt: quota.resetAt || null, }); }); } break; case "qoder": // Qoder ships a `user` quota and (optionally) an `organization` // quota, both with same shape: {total, used, remaining, unit, resetAt}. // Skip an organization bucket when its total is 0 — most personal // Qoder accounts won't have one and rendering "0/0" is misleading. // Don't forward Qoder's `remaining` field: it's an absolute credit // count, but getRemainingPercentage / QuotaTable interpret // `remaining` as a 0-100 percentage and would render 348 credits // as "348%". The percentage is computed from used/total instead. if (data.quotas) { Object.entries(data.quotas).forEach(([quotaType, quota]) => { if (quotaType === "organization" && (!quota || (Number(quota.total) || 0) === 0)) { return; } normalizedQuotas.push({ name: quotaType === "user" ? "Personal" : quotaType === "organization" ? "Organization" : quotaType, used: quota.used || 0, total: quota.total || 0, unit: quota.unit, resetAt: quota.resetAt || null, }); }); } break; case "claude": if (data.message) { // Handle error message case normalizedQuotas.push({ name: "error", used: 0, total: 0, resetAt: null, message: data.message, }); } else if (data.quotas) { Object.entries(data.quotas).forEach(([name, quota]) => { normalizedQuotas.push({ name, used: quota.used || 0, total: quota.total || 0, resetAt: quota.resetAt || null, }); }); } break; case "vercel-ai-gateway": // Vercel returns currency credit balance, not request quotas. // The 'Remaining (USD)' row needs explicit remainingPercentage because // its used/total values would otherwise compute the wrong direction // (e.g. used=95.5 / total=100 → 4% instead of 96%). if (data.quotas) { Object.entries(data.quotas).forEach(([name, quota]) => { normalizedQuotas.push({ name, used: quota.used || 0, total: quota.total || 0, resetAt: quota.resetAt || null, remainingPercentage: quota.remainingPercentage, }); }); } break; default: // Generic fallback for unknown providers if (data.quotas) { Object.entries(data.quotas).forEach(([name, quota]) => { normalizedQuotas.push({ name, used: quota.used || 0, total: quota.total || 0, resetAt: quota.resetAt || null, }); }); } } } catch (error) { console.error(`Error parsing quota data for ${provider}:`, error); return []; } // Sort quotas according to PROVIDER_MODELS order const modelOrder = getModelsByProviderId(provider); if (modelOrder.length > 0) { const orderMap = new Map(modelOrder.map((m, i) => [m.id, i])); normalizedQuotas.sort((a, b) => { // Use modelKey for antigravity, otherwise use name const keyA = a.modelKey || a.name; const keyB = b.modelKey || b.name; const orderA = orderMap.get(keyA) ?? 999; const orderB = orderMap.get(keyB) ?? 999; return orderA - orderB; }); } return normalizedQuotas; }