Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions open-sse/services/accountFallback.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,53 @@ export function buildClearModelLocksUpdate(connection) {
return cleared;
}

/**
* Providers whose persisted quota `resetAt` is trustworthy enough to
* proactively skip a fully-depleted account during routing (instead of
* waiting for it to error out). Kiro reports an exact reset timestamp via its
* usage API but does not surface it on chat errors, so depletion can only be
* known from the persisted quota snapshot.
*/
export const QUOTA_DEPLETION_PROVIDERS = new Set(["kiro"]);

/**
* Earliest future resetAt among a connection's depleted quota buckets, or null
* when the account is still usable.
*
* Returns a timestamp only when EVERY known bucket (total > 0) is used up and
* its reset is still in the future. An account that still has room in any
* bucket — or whose reset has already passed (quota refreshed, snapshot stale) —
* is never blocked.
*
* @param {object} connection - Full connection record (must carry quotaInfos)
* @returns {string|null} ISO reset timestamp to skip until, or null
*/
export function getQuotaResetUntil(connection) {
if (!connection || !QUOTA_DEPLETION_PROVIDERS.has(connection.provider)) return null;
const quotas = connection.quotaInfos;
if (!Array.isArray(quotas) || quotas.length === 0) return null;

const now = Date.now();
let earliest = null;
for (const q of quotas) {
const total = Number(q?.total) || 0;
if (total <= 0) continue; // unlimited/unknown bucket — not blocking
const used = Number(q?.used) || 0;
if (used < total) return null; // still has room somewhere → usable
const resetMs = q?.resetAt ? new Date(q.resetAt).getTime() : 0;
if (!resetMs || resetMs <= now) return null; // reset passed/unknown → usable
if (!earliest || resetMs < earliest) earliest = resetMs;
}
return earliest ? new Date(earliest).toISOString() : null;
}

/**
* True when a connection's persisted quota is fully depleted and not yet reset.
*/
export function isQuotaDepleted(connection) {
return getQuotaResetUntil(connection) !== null;
}

/**
* Filter available accounts (not in cooldown)
*/
Expand Down
176 changes: 176 additions & 0 deletions open-sse/services/usage/normalize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { getModelsByProviderId } from "../../config/providerModels.js";

/**
* Parse provider-specific quota structures into a normalized array.
*
* Shared between the dashboard client (display) and the backend usage route
* (persistence): the backend stores the result as `quotaInfos` on each
* connection so the connection list can ship last-known quota to the UI
* without waiting for a live refetch.
*
* @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]) => {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,27 @@ export default function ProviderLimits() {
const nextTotals = getSafeTotals(data.totals, connectionList.length);

setConnections(connectionList);

// Hydrate quota from the persisted snapshot bundled with each
// connection, so the table shows last-known values instantly before
// the live refetch completes. Live fetchQuota() overwrites this later.
const hydrated = {};
connectionList.forEach((conn) => {
if (Array.isArray(conn.quotaInfos) && conn.quotaInfos.length > 0) {
const quotaEntry = {
quotas: conn.quotaInfos,
plan: conn.quotaPlan || null,
message: conn.quotaMessage || null,
raw: null,
};
hydrated[conn.id] = quotaEntry;
setQuotaCache(conn.id, quotaEntry);
}
});
if (Object.keys(hydrated).length > 0) {
setQuotaData((prev) => ({ ...hydrated, ...prev }));
}

setProviderOptions(getProviderOptions(data.providerOptions));
setPagination(nextPagination);
setTotals(nextTotals);
Expand Down Expand Up @@ -966,7 +987,7 @@ export default function ProviderLimits() {
</div>

<div className="px-2 py-1.5">
{isLoading ? (
{isLoading && !quota ? (
<div className="text-center py-5 text-text-muted">
<span className="material-symbols-outlined text-[28px] animate-spin">
progress_activity
Expand Down
Loading