import { getModelsByProviderId } from "open-sse/config/providerModels.js"; /** * 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; }