diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index f19a531309..d485184976 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -274,6 +274,8 @@ export function classifyErrorText(errorText) { if ( lower.includes("quota exceeded") || lower.includes("quota depleted") || + lower.includes("quota will reset") || + lower.includes("your quota will reset") || lower.includes("billing") ) { return RateLimitReason.QUOTA_EXHAUSTED; diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 26c3e6d270..cb2f020e14 100644 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -207,17 +207,21 @@ export async function refreshKimiCodingToken(refreshToken, log) { */ export async function refreshClaudeOAuthToken(refreshToken, log) { try { + // Standard OAuth2 token refresh uses form-urlencoded (not JSON) + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: PROVIDERS.claude.clientId, + }); + const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, { method: "POST", headers: { - "Content-Type": "application/json", + "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", + "anthropic-beta": "oauth-2025-04-20", }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.claude.clientId, - }), + body: params.toString(), }); if (!response.ok) { diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 49b6ffd255..b4fe38f854 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -658,23 +658,33 @@ function getAntigravityPlanLabel(subscriptionInfo) { /** * Antigravity Usage - Fetch quota from Google Cloud Code API * Now calls loadCodeAssist ONCE (cached) and reuses for projectId + plan. + * Uses retrieveUserQuota API (same as Gemini CLI) for accurate quota data across all tiers. */ async function getAntigravityUsage(accessToken, providerSpecificData) { try { - // Single cached call for subscription info (provides both projectId and plan) const subscriptionInfo = await getAntigravitySubscriptionInfoCached(accessToken); const projectId = subscriptionInfo?.cloudaicompanionProject || null; - // Fetch quota data - const response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "User-Agent": ANTIGRAVITY_CONFIG.userAgent, - "Content-Type": "application/json", - }, - body: JSON.stringify(projectId ? { project: projectId } : {}), - }); + if (!projectId) { + return { + plan: getAntigravityPlanLabel(subscriptionInfo), + message: "Antigravity project ID not available.", + }; + } + + // Use retrieveUserQuota API (same as Gemini CLI) - works correctly for both Free and Pro tiers + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ project: projectId }), + signal: AbortSignal.timeout(10000), + } + ); if (response.status === 403) { return { message: "Antigravity access forbidden. Check subscription." }; @@ -685,54 +695,26 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { } const data = await response.json(); - const dataObj = toRecord(data); - const modelEntries = toRecord(dataObj.models); const quotas: Record = {}; - // Parse model quotas (inspired by vscode-antigravity-cockpit) - if (Object.keys(modelEntries).length > 0) { - // Filter only recommended/important models (must match PROVIDER_MODELS ag ids) - const importantModels = [ - "claude-opus-4-6-thinking", - "claude-sonnet-4-6", - "gemini-3.1-pro-high", - "gemini-3.1-pro-low", - "gemini-3-flash", - "gpt-oss-120b-medium", - ]; + // Parse buckets from retrieveUserQuota response (same format as Gemini CLI) + if (Array.isArray(data.buckets)) { + for (const bucket of data.buckets) { + if (!bucket.modelId || bucket.remainingFraction == null) continue; - for (const [modelKey, infoValue] of Object.entries(modelEntries)) { - const info = toRecord(infoValue); - const quotaInfo = toRecord(info.quotaInfo); - // Skip models without quota info - if (Object.keys(quotaInfo).length === 0) { - continue; - } - - // Skip internal models and non-important models - if (info.isInternal === true || !importantModels.includes(modelKey)) { - continue; - } - - const remainingFraction = toNumber(quotaInfo.remainingFraction, 0); + const remainingFraction = toNumber(bucket.remainingFraction, 0); const remainingPercentage = remainingFraction * 100; - - // Convert percentage to used/total for UI compatibility - // QUOTA_NORMALIZED_BASE is an arbitrary base for converting fractions - // to integer used/total pairs that the dashboard UI can display as bars. const QUOTA_NORMALIZED_BASE = 1000; const total = QUOTA_NORMALIZED_BASE; const remaining = Math.round(total * remainingFraction); - const used = total - remaining; + const used = Math.max(0, total - remaining); - // Use modelKey as key (matches PROVIDER_MODELS id) - quotas[modelKey] = { + quotas[bucket.modelId] = { used, total, - resetAt: parseResetTime(quotaInfo.resetTime), + resetAt: parseResetTime(bucket.resetTime), remainingPercentage, unlimited: false, - displayName: typeof info.displayName === "string" ? info.displayName : modelKey, }; } } @@ -743,7 +725,7 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { subscriptionInfo, }; } catch (error) { - return { message: `Antigravity error: ${error.message}` }; + return { message: `Antigravity error: ${(error as Error).message}` }; } } diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 367d5b2730..d39b3c184b 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -122,6 +122,17 @@ export async function parseUpstreamError(response, provider = null) { retryAfterMs = parseAntigravityRetryTime(messageStr); } + // Also parse retry time for other providers (Qwen, etc.) with "quota will reset after XhYmZs" format + if (response.status === 429 && !retryAfterMs) { + retryAfterMs = parseAntigravityRetryTime(messageStr); + } + + // Cap maximum retry time at 24 hours to prevent infinite wait + const MAX_RETRY_MS = 24 * 60 * 60 * 1000; + if (retryAfterMs && retryAfterMs > MAX_RETRY_MS) { + retryAfterMs = MAX_RETRY_MS; + } + return { statusCode: response.status, message: messageStr,