diff --git a/src/components/CloudAccountCard.tsx b/src/components/CloudAccountCard.tsx index a548a61..2253dce 100644 --- a/src/components/CloudAccountCard.tsx +++ b/src/components/CloudAccountCard.tsx @@ -37,6 +37,7 @@ import { import { useState } from 'react'; import { useSetAccountProxy } from '@/hooks/useCloudAccounts'; import { isValidProxyUrl } from '@/utils/url'; +import { getValidationBlockedStatusLabel } from '@/components/accountValidationStatus'; type ModelQuotaEntry = [string, CloudQuotaModelInfo]; @@ -336,6 +337,12 @@ export function CloudAccountCard({ } }; + const validationBlockedStatusLabel = getValidationBlockedStatusLabel( + account.status, + account.status_reason, + t, + ); + return (
{account.provider.toUpperCase()} @@ -484,9 +495,9 @@ export function CloudAccountCard({ {t('cloud.card.active')} )} - {account.status === 'rate_limited' && ( + {validationBlockedStatusLabel && ( - {t('cloud.card.rateLimited')} + {validationBlockedStatusLabel} )}
@@ -636,6 +647,12 @@ export function CompactCloudAccountCard({ } }; + const validationBlockedStatusLabel = getValidationBlockedStatusLabel( + account.status, + account.status_reason, + t, + ); + return (
{account.avatar_url ? ( @@ -656,7 +673,11 @@ export function CompactCloudAccountCard({ {account.name || t('cloud.card.unknown')} {account.provider.toUpperCase()} @@ -673,6 +694,9 @@ export function CompactCloudAccountCard({
{account.email} + {validationBlockedStatusLabel && ( + {validationBlockedStatusLabel} + )} {shouldShowAiCredits && aiCredits && ( diff --git a/src/services/CloudMonitorService.ts b/src/services/CloudMonitorService.ts index 27378cc..9de4abc 100644 --- a/src/services/CloudMonitorService.ts +++ b/src/services/CloudMonitorService.ts @@ -3,6 +3,16 @@ import { CloudAccountRepo } from '../ipc/database/cloudHandler'; import { GoogleAPIService } from './GoogleAPIService'; import { AutoSwitchService } from './AutoSwitchService'; import { logger } from '../utils/logger'; +import { classifyAccountStatusFromError } from '../utils/account-status'; + +function hasReusableCachedQuota(account: { + quota?: { models?: Record }; +}): boolean { + if (!account.quota || !account.quota.models) { + return false; + } + return Object.keys(account.quota.models).length > 0; +} export class CloudMonitorService { private static intervalId: NodeJS.Timeout | null = null; @@ -106,14 +116,23 @@ export class CloudMonitorService { const newToken = await GoogleAPIService.refreshAccessToken( account.token.refresh_token, account.proxy_url, + account.token.oauth_client_key, ); account.token.access_token = newToken.access_token; account.token.expires_in = newToken.expires_in; account.token.expiry_timestamp = now + newToken.expires_in; + account.token.oauth_client_key = GoogleAPIService.normalizeRefreshedOAuthClientKey( + account.token, + newToken.oauth_client_key, + ); await CloudAccountRepo.updateToken(account.id, account.token); accessToken = newToken.access_token; } catch (refreshError) { logger.error(`Monitor: Token refresh failed for ${account.email}`, refreshError); + const classified = classifyAccountStatusFromError(refreshError); + if (classified) { + await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason); + } continue; } } @@ -139,9 +158,18 @@ export class CloudMonitorService { // 3. Update DB await CloudAccountRepo.updateQuota(account.id, quota); await CloudAccountRepo.updateLastUsed(account.id); + await CloudAccountRepo.setAccountStatus(account.id, 'active', null); } catch (error) { logger.error(`Monitor: Failed to update ${account.email}`, error); - // Could mark status as 'error' or 'rate_limited' if 429 + const classified = classifyAccountStatusFromError(error); + if (classified) { + await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason); + if (classified.status === 'rate_limited' && hasReusableCachedQuota(account)) { + logger.warn( + `Monitor: Quota request rate-limited for ${account.email}, keeping cached quota as fallback.`, + ); + } + } } } diff --git a/src/tests/unit/cloud-monitor.test.ts b/src/tests/unit/cloud-monitor.test.ts index 9025815..3351f6b 100644 --- a/src/tests/unit/cloud-monitor.test.ts +++ b/src/tests/unit/cloud-monitor.test.ts @@ -61,7 +61,7 @@ describe('CloudMonitorService', () => { await pollPromise; expect(CloudAccountRepo.getAccounts).toHaveBeenCalled(); - expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token'); + expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token', undefined); expect(CloudAccountRepo.updateQuota).toHaveBeenCalledWith('acc1', expect.anything()); expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1'); expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled(); @@ -93,9 +93,13 @@ describe('CloudMonitorService', () => { await vi.advanceTimersByTimeAsync(1000); await pollPromise; - expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith('ref_token'); + expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith( + 'ref_token', + undefined, + undefined, + ); expect(CloudAccountRepo.updateToken).toHaveBeenCalled(); - expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token'); + expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token', undefined); }); describe('handleAppFocus (Smart Refresh)', () => { diff --git a/src/utils/account-status.ts b/src/utils/account-status.ts new file mode 100644 index 0000000..92c1b32 --- /dev/null +++ b/src/utils/account-status.ts @@ -0,0 +1,73 @@ +export const RATE_LIMIT_HINTS = [ + 'resource_exhausted', + 'too many requests', + '429', + 'rate limit', + 'rate-limited', + 'risk control', + 'risk-controlled', + 'frozen', +] as const; + +export const OAUTH_HINTS = [ + 'unauthorized_client', + 'invalid_client', + 'invalid_grant', + 'oauth client', + 'not authorized', + 'token expired', + 'reauth', + 'verify your account', + 'further action is required', + 'validation required', + 'validation_url', + 'appeal_url', +] as const; + +function includesAny(text: string, hints: readonly string[]): boolean { + return hints.some((hint) => text.includes(hint)); +} + +export function isRateLimitReason(reason: string): boolean { + return includesAny(reason.toLowerCase(), RATE_LIMIT_HINTS); +} + +export function isOAuthReauthReason(reason: string): boolean { + return includesAny(reason.toLowerCase(), OAUTH_HINTS); +} + +export function extractErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return String(error); +} + +export function classifyAccountStatusFromError( + error: unknown, +): { status: 'rate_limited' | 'expired'; reason: string } | null { + const reason = extractErrorMessage(error).trim(); + if (!reason) { + return null; + } + + const normalizedReason = reason.toLowerCase(); + if (isRateLimitReason(normalizedReason)) { + return { status: 'rate_limited', reason }; + } + if (isOAuthReauthReason(normalizedReason)) { + return { status: 'expired', reason }; + } + + if (normalizedReason.includes('forbidden')) { + return { status: 'rate_limited', reason }; + } + if (normalizedReason.includes('unauthorized')) { + return { status: 'expired', reason }; + } + + return null; +}