diff --git a/open-sse/services/accountFallback.js b/open-sse/services/accountFallback.js index 8d280da412..36545d18cc 100644 --- a/open-sse/services/accountFallback.js +++ b/open-sse/services/accountFallback.js @@ -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) */ diff --git a/open-sse/services/usage/normalize.js b/open-sse/services/usage/normalize.js new file mode 100644 index 0000000000..af07f39002 --- /dev/null +++ b/open-sse/services/usage/normalize.js @@ -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} 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; +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 9e4b4cc9b2..5ea9e44067 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -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); @@ -966,7 +987,7 @@ export default function ProviderLimits() {
- {isLoading ? ( + {isLoading && !quota ? (
progress_activity diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index df5edab586..af18851e24 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -1,4 +1,6 @@ -import { getModelsByProviderId } from "open-sse/config/providerModels.js"; +// parseQuotaData is shared with the backend usage route (persistence), so it +// lives in open-sse. Re-export it here to keep existing imports working. +export { parseQuotaData } from "open-sse/services/usage/normalize.js"; // ─── Constants ─────────────────────────────────────────────────────────────── export const QUOTA_CACHE_KEY = "quotaCacheData"; @@ -299,171 +301,3 @@ export function getRemainingPercentage(quota) { 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; -} diff --git a/src/app/api/providers/client/route.js b/src/app/api/providers/client/route.js index 22bbb282c8..885e391309 100644 --- a/src/app/api/providers/client/route.js +++ b/src/app/api/providers/client/route.js @@ -9,6 +9,9 @@ const SAFE_FIELDS = [ "testStatus", "lastError", "lastErrorAt", "errorCode", "expiresAt", "lastUsedAt", "consecutiveUseCount", "createdAt", "updatedAt", + // Persisted last-known quota (written by the usage route) so the UI can + // render immediately without waiting for a per-connection live refetch. + "quotaInfos", "quotaPlan", "quotaMessage", "quotaUpdatedAt", ]; const SAFE_PSD_FIELDS = [ diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js index 4b775b18fc..421cad5523 100644 --- a/src/app/api/usage/[connectionId]/route.js +++ b/src/app/api/usage/[connectionId]/route.js @@ -3,6 +3,7 @@ import "open-sse/index.js"; import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb"; import { getUsageForProvider } from "open-sse/services/usage.js"; +import { parseQuotaData } from "open-sse/services/usage/normalize.js"; import { getExecutor } from "open-sse/executors/index.js"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { USAGE_APIKEY_PROVIDERS } from "@/shared/constants/providers"; @@ -179,6 +180,25 @@ export async function GET(request, { params }) { } } + // Persist last-known quota onto the connection so the connection list can + // ship it to the UI without waiting for a live refetch. Only overwrite + // quotaInfos when we actually got buckets back — keeps the last good + // snapshot when a provider transiently returns an auth/empty response. + try { + const quotaInfos = parseQuotaData(connection.provider, usage); + const quotaUpdate = { + quotaUpdatedAt: new Date().toISOString(), + quotaPlan: usage?.plan ?? null, + quotaMessage: usage?.message ?? null, + }; + if (quotaInfos.length > 0) { + quotaUpdate.quotaInfos = quotaInfos; + } + await updateProviderConnection(connection.id, quotaUpdate); + } catch (persistError) { + console.warn(`[Usage] ${connection.provider}: failed to persist quota: ${persistError.message}`); + } + return Response.json(usage); } catch (error) { const provider = connection?.provider ?? "unknown"; diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 241082d4fb..8daa1d080f 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -1,6 +1,6 @@ import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings } from "@/lib/localDb"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; -import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js"; +import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil, isQuotaDepleted, getQuotaResetUntil } from "open-sse/services/accountFallback.js"; import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js"; import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.js"; import * as log from "../utils/logger.js"; @@ -8,6 +8,11 @@ import * as log from "../utils/logger.js"; // Mutex to prevent race conditions during account selection let selectionMutex = Promise.resolve(); +// Throttle "out of quota" skip logs (selection runs per-request, so a depleted +// account would otherwise flood the console log). Per-connection, last-logged ms. +const QUOTA_SKIP_LOG_THROTTLE_MS = 60_000; +const quotaSkipLogAt = new Map(); + /** * Get provider credentials from localDb * Filters out unavailable accounts and returns the selected account based on strategy @@ -60,13 +65,31 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu return null; } - // Filter out model-locked and excluded connections + // Filter out model-locked, quota-depleted, and excluded connections. + // Collect quota-depleted skips so we can surface them to the console log. + const quotaDepletedSkips = []; const availableConnections = connections.filter(c => { if (excludeSet.has(c.id)) return false; if (isModelLockActive(c, model)) return false; + const quotaResetUntil = getQuotaResetUntil(c); + if (quotaResetUntil) { + quotaDepletedSkips.push({ conn: c, quotaResetUntil }); + return false; // out of quota — skip until resetAt + } return true; }); + // Log each quota-based skip (throttled per account) so it shows in + // /dashboard/console-log: which account is out of quota and when it resets. + for (const { conn: c, quotaResetUntil } of quotaDepletedSkips) { + const last = quotaSkipLogAt.get(c.id) || 0; + if (Date.now() - last >= QUOTA_SKIP_LOG_THROTTLE_MS) { + quotaSkipLogAt.set(c.id, Date.now()); + const connName = c.displayName || c.name || c.email || c.id?.slice(0, 8); + log.info("AUTH", `${provider} | skip "${connName}" — out of quota, resets ${quotaResetUntil} (${formatRetryAfter(quotaResetUntil)})`); + } + } + log.debug("AUTH", `${provider} | available: ${availableConnections.length}/${connections.length}`); connections.forEach(c => { const excluded = excludeSet.has(c.id); @@ -78,9 +101,14 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu }); if (availableConnections.length === 0) { - // Find earliest lock expiry across all connections for retry timing - const lockedConns = connections.filter(c => isModelLockActive(c, model)); - const expiries = lockedConns.map(c => getEarliestModelLockUntil(c)).filter(Boolean); + // Find earliest unavailability expiry (model lock or quota reset) for retry timing + const lockedConns = connections.filter(c => isModelLockActive(c, model) || isQuotaDepleted(c)); + const expiries = lockedConns.map(c => { + const lockUntil = getEarliestModelLockUntil(c); + const quotaReset = getQuotaResetUntil(c); + if (lockUntil && quotaReset) return lockUntil < quotaReset ? lockUntil : quotaReset; + return lockUntil || quotaReset; + }).filter(Boolean); const earliest = expiries.sort()[0] || null; if (earliest) { const earliestConn = lockedConns[0];