feat(usage): dedup + cache Claude quota calls to avoid 429
Multiple tabs/accounts/auto-refresh funneled straight to Anthropic and tripped 429. Add a 120s TTL cache keyed by access token with in-flight promise dedup, serve the last good read on soft failure, and thread a force flag through getUsageForProvider for manual refresh. Also lower the dashboard poll cadence (180s to 600s) and stable group-by-provider so connection order stops jumping. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ const USAGE_HANDLERS = {
|
||||
github: (c) => getGitHubUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||
"gemini-cli": (c) => getGeminiUsage(c.accessToken, c.providerDataWithProjectId, c.proxyOptions),
|
||||
antigravity: (c) => getAntigravityUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions),
|
||||
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }),
|
||||
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions),
|
||||
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||
qoder: async (c) => {
|
||||
@@ -56,7 +56,7 @@ const USAGE_HANDLERS = {
|
||||
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
|
||||
};
|
||||
|
||||
export async function getUsageForProvider(connection, proxyOptions = null) {
|
||||
export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
|
||||
const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection;
|
||||
const providerDataWithProjectId = {
|
||||
...(providerSpecificData || {}),
|
||||
@@ -65,5 +65,13 @@ export async function getUsageForProvider(connection, proxyOptions = null) {
|
||||
|
||||
const handler = USAGE_HANDLERS[provider];
|
||||
if (!handler) return { message: `Usage API not implemented for ${provider}` };
|
||||
return await handler({ provider, accessToken, apiKey, providerSpecificData, providerDataWithProjectId, proxyOptions });
|
||||
return await handler({
|
||||
provider,
|
||||
accessToken,
|
||||
apiKey,
|
||||
providerSpecificData,
|
||||
providerDataWithProjectId,
|
||||
proxyOptions,
|
||||
force: options.force === true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,43 @@ const CLAUDE_CONFIG = {
|
||||
const OAUTH_429_COOLDOWN_MS = 180000;
|
||||
const oauthCooldown = new Map();
|
||||
|
||||
export async function getClaudeUsage(accessToken, proxyOptions = null) {
|
||||
// Dedup + short TTL cache per access token. Many tabs / many accounts / auto-refresh
|
||||
// all funnel through here; without this each call hits Anthropic and triggers 429.
|
||||
const USAGE_CACHE_TTL_MS = 300000;
|
||||
const usageCache = new Map(); // token -> { promise } | { result, expiresAt }
|
||||
|
||||
export async function getClaudeUsage(accessToken, proxyOptions = null, options = {}) {
|
||||
const force = options?.force === true;
|
||||
|
||||
// Serve in-flight or fresh cached result (skip on manual force)
|
||||
if (!force && accessToken) {
|
||||
const hit = usageCache.get(accessToken);
|
||||
if (hit?.promise) return hit.promise;
|
||||
if (hit && hit.expiresAt > Date.now()) return hit.result;
|
||||
}
|
||||
|
||||
const stale = (!force && accessToken && usageCache.get(accessToken)?.result) || null;
|
||||
|
||||
const promise = (async () => {
|
||||
const result = await fetchClaudeUsageRaw(accessToken, proxyOptions);
|
||||
// Only cache real quota data, not soft-failure {message: ...} payloads
|
||||
if (accessToken && result?.quotas) {
|
||||
usageCache.set(accessToken, {
|
||||
result,
|
||||
expiresAt: Date.now() + USAGE_CACHE_TTL_MS,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
// Soft failure (429/error): prefer the last good read over a transient error
|
||||
if (stale) return stale;
|
||||
return result;
|
||||
})();
|
||||
|
||||
if (accessToken) usageCache.set(accessToken, { promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) {
|
||||
try {
|
||||
// Skip OAuth usage call while this token is cooling down from a recent 429
|
||||
const cooldownUntil = oauthCooldown.get(accessToken);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getModelsByProviderId } from "open-sse/config/providerModels.js";
|
||||
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 CLAUDE_REFRESH_INTERVAL_MS = 600000;
|
||||
export const DEPLETED_QUOTA_THRESHOLD = 5;
|
||||
export const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
|
||||
export const CONNECTIONS_PAGE_SIZE = 20;
|
||||
@@ -36,6 +36,17 @@ export function getConnectionQuotaRemaining(connection, quotaData) {
|
||||
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,
|
||||
@@ -58,7 +69,7 @@ export function sortVisibleConnections(
|
||||
});
|
||||
}
|
||||
|
||||
if (!expiringFirst) return connections;
|
||||
if (!expiringFirst) return groupByProviderStable(connections);
|
||||
|
||||
const getEarliestResetTime = (connection) => {
|
||||
const resetTimes = (quotaData[connection.id]?.quotas || [])
|
||||
|
||||
Reference in New Issue
Block a user