From cd4003bc8bf4013cc735ca8af06e595c4c144566 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 13 Aug 2026 11:27:57 +0700 Subject: [PATCH] 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 --- open-sse/services/usage.js | 14 +++++-- open-sse/services/usage/claude.js | 38 ++++++++++++++++++- .../usage/components/ProviderLimits/utils.js | 15 +++++++- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 70a38ea6..65062467 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -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, + }); } diff --git a/open-sse/services/usage/claude.js b/open-sse/services/usage/claude.js index 85ab8e6f..ce3e01a0 100644 --- a/open-sse/services/usage/claude.js +++ b/open-sse/services/usage/claude.js @@ -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); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 9f185b83..ffd861e0 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -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 || [])