644 lines
21 KiB
JavaScript
644 lines
21 KiB
JavaScript
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 = 600000;
|
||
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;
|
||
}
|
||
|
||
// Stable group-by-provider: first-seen provider order, original order within group.
|
||
function groupByProviderStable(connections) {
|
||
const seen = new Map();
|
||
for (const conn of connections) {
|
||
const key = conn.provider || "";
|
||
if (!seen.has(key)) seen.set(key, []);
|
||
seen.get(key).push(conn);
|
||
}
|
||
return Array.from(seen.values()).flat();
|
||
}
|
||
|
||
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 groupByProviderStable(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);
|
||
}
|
||
|
||
export function getQuotaVisibilityKey(quota) {
|
||
if (!quota || typeof quota !== "object") return "";
|
||
return String(quota.modelKey || quota.name || "").trim();
|
||
}
|
||
|
||
function getProviderHiddenQuotaSet(provider, quotaVisibility) {
|
||
const hidden = quotaVisibility?.[provider]?.hidden;
|
||
return new Set(Array.isArray(hidden) ? hidden.map(String) : []);
|
||
}
|
||
|
||
export function filterQuotasByVisibility(provider, quotas = [], quotaVisibility = {}) {
|
||
if (!Array.isArray(quotas) || quotas.length === 0) return [];
|
||
const hidden = getProviderHiddenQuotaSet(provider, quotaVisibility);
|
||
if (hidden.size === 0) return quotas;
|
||
return quotas.filter((quota) => !hidden.has(getQuotaVisibilityKey(quota)));
|
||
}
|
||
|
||
export function getHiddenQuotaRows(provider, quotas = [], quotaVisibility = {}) {
|
||
if (!Array.isArray(quotas) || quotas.length === 0) return [];
|
||
const hidden = getProviderHiddenQuotaSet(provider, quotaVisibility);
|
||
if (hidden.size === 0) return [];
|
||
return quotas.filter((quota) => hidden.has(getQuotaVisibilityKey(quota)));
|
||
}
|
||
|
||
/**
|
||
* 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<Object>} 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]) => {
|
||
let displayName = quotaType;
|
||
if (quotaType === "spark_session") displayName = "Spark (5h)";
|
||
else if (quotaType === "spark_weekly") displayName = "Spark (Weekly)";
|
||
else if (quotaType === "session") displayName = "5h";
|
||
else if (quotaType === "weekly") displayName = "Weekly";
|
||
else if (quotaType === "review_session") displayName = "Review (5h)";
|
||
else if (quotaType === "review_weekly") displayName = "Review (Weekly)";
|
||
|
||
normalizedQuotas.push({
|
||
name: displayName,
|
||
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;
|
||
|
||
case "codebuddy-cn":
|
||
// CodeBuddy CN mixes recurring refill packs ("Monthly"/"Weekly"/...)
|
||
// with one-shot bonus packs ("Bonus Pack N"). Forward `recurring`
|
||
// so the UI can show "Expires in" for bonus packs (whose resetAt is
|
||
// a hard expiry, not a refresh) instead of "Reset in".
|
||
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,
|
||
recurring: quota.recurring !== false,
|
||
});
|
||
});
|
||
}
|
||
break;
|
||
|
||
case "grok-cli":
|
||
// Grok Build credits (on-demand window + prepaid balance).
|
||
// Do NOT forward absolute `remaining` — getRemainingPercentage treats
|
||
// it as a 0–100 percentage (same as Qoder). Use remainingPercentage.
|
||
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;
|
||
|
||
case "kimi":
|
||
// Weekly / Ratelimit from /v1/usages. Prefer remainingPercentage only.
|
||
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;
|
||
|
||
case "deepseek":
|
||
// Credit balance — remainingPercentage only (no absolute remaining).
|
||
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;
|
||
|
||
case "freebuff":
|
||
// Session quotas keyed by model id — label rows with the friendly
|
||
// displayName (from the registry) and keep modelKey for ordering.
|
||
if (data.quotas) {
|
||
Object.entries(data.quotas).forEach(([modelKey, quota]) => {
|
||
normalizedQuotas.push({
|
||
name: quota.displayName || modelKey,
|
||
modelKey,
|
||
used: quota.used || 0,
|
||
total: quota.total || 0,
|
||
resetAt: quota.resetAt || null,
|
||
recurring: quota.recurring !== false,
|
||
});
|
||
});
|
||
}
|
||
break;
|
||
|
||
case "groq":
|
||
// Requests/Tokens rate-limit windows from response headers — absolute
|
||
// used/total (calculatePercentage derives the bar), like Codex/Kiro.
|
||
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 "ollama":
|
||
// Session (5h) / Weekly (7d) usage % from ollama.com/api/usage.
|
||
// remainingPercentage only — no absolute remaining (UI treats remaining as %).
|
||
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;
|
||
|
||
case "zed":
|
||
// Edit predictions + optional hosted model_requests; unlimited uses remainingPercentage.
|
||
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,
|
||
unlimited: quota.unlimited,
|
||
});
|
||
});
|
||
}
|
||
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;
|
||
}
|