From b6fb443f572cc5c1bb4e3763abd1aa6fa3ba14e5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 8 Apr 2026 23:55:55 -0400 Subject: [PATCH 1/2] fix(cliproxy): refresh non-default gemini quota tokens --- src/cliproxy/auth-utils.ts | 33 +++++++-- src/cliproxy/quota-fetcher-gemini-cli.ts | 27 ++++--- tests/unit/cliproxy/auth-utils.test.ts | 27 ++++--- .../cliproxy/quota-fetcher-gemini-cli.test.ts | 72 ++++++++++++++++++- .../account/flow-viz/account-card.tsx | 2 +- .../account/shared/account-quota-panel.tsx | 21 +----- .../shared/quota-tooltip-content.tsx | 61 ++++++++++------ ui/src/components/ui/popover.tsx | 2 +- ui/src/components/ui/tooltip.tsx | 6 +- .../account/flow-viz/account-card.test.tsx | 3 + .../shared/quota-tooltip-content.test.tsx | 24 +++++++ 11 files changed, 207 insertions(+), 71 deletions(-) diff --git a/src/cliproxy/auth-utils.ts b/src/cliproxy/auth-utils.ts index 77e371b1..79f8628f 100644 --- a/src/cliproxy/auth-utils.ts +++ b/src/cliproxy/auth-utils.ts @@ -16,12 +16,35 @@ export function sanitizeEmail(email: string): string { * Check if token is expired based on the expired timestamp. * Returns false if timestamp is missing or invalid (fail-open for quota display). */ -export function isTokenExpired(expiredStr?: string): boolean { - if (!expiredStr) return false; +export function getTokenExpiryTimestamp(expiredValue?: string | number | null): number | null { + if (expiredValue === undefined || expiredValue === null || expiredValue === '') { + return null; + } + try { - const expiredDate = new Date(expiredStr); - return expiredDate.getTime() < Date.now(); + if (typeof expiredValue === 'number') { + return Number.isFinite(expiredValue) ? expiredValue : null; + } + + const trimmed = expiredValue.trim(); + if (!trimmed) { + return null; + } + + if (/^\d+$/.test(trimmed)) { + const numericTimestamp = Number(trimmed); + return Number.isFinite(numericTimestamp) ? numericTimestamp : null; + } + + const expiredDate = new Date(trimmed); + const expiredAt = expiredDate.getTime(); + return Number.isNaN(expiredAt) ? null : expiredAt; } catch { - return false; + return null; } } + +export function isTokenExpired(expiredValue?: string | number | null): boolean { + const expiredAt = getTokenExpiryTimestamp(expiredValue); + return expiredAt !== null ? expiredAt < Date.now() : false; +} diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 0ec6afef..64882d4c 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { getAuthDir } from './config-generator'; import { getProviderAccounts, getPausedDir } from './account-manager'; -import { sanitizeEmail, isTokenExpired } from './auth-utils'; +import { getTokenExpiryTimestamp, sanitizeEmail, isTokenExpired } from './auth-utils'; import { refreshGeminiToken } from './auth/gemini-token-refresh'; import { buildGeminiCliBucketsFromParsedBuckets, @@ -38,7 +38,7 @@ interface GeminiCliAuthData { accessToken: string; projectId: string | null; isExpired: boolean; - expiresAt: string | null; + expiresAt: string | number | null; } /** Raw bucket from API response */ @@ -130,17 +130,23 @@ function extractAccessToken(data: Record): string | null { * Extract expiry from Gemini auth file data * Handles both flat (expired) and nested (token.expiry) structures */ -function extractExpiry(data: Record): string | null { +function extractExpiry(data: Record): string | number | null { // Flat structure: { expired: "..." } if (typeof data.expired === 'string') { return data.expired; } + if (typeof data.expired === 'number') { + return data.expired; + } // Nested structure: { token: { expiry: "..." } } if (data.token && typeof data.token === 'object') { const token = data.token as Record; if (typeof token.expiry === 'string') { return token.expiry; } + if (typeof token.expiry === 'number') { + return token.expiry; + } } return null; } @@ -736,19 +742,20 @@ export async function fetchGeminiCliQuota( // Proactive refresh: refresh if expired OR expiring within 5 minutes const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; + const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); const shouldRefresh = - authData.isExpired || - !authData.expiresAt || - new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; + authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS; + let attemptedRefresh = false; if (shouldRefresh) { + attemptedRefresh = true; if (verbose) console.error( authData.isExpired ? '[i] Token expired, refreshing...' : '[i] Token expiring soon, proactive refresh...' ); - const refreshResult = await refreshGeminiToken(); + const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { if (verbose) console.error('[i] Token refreshed successfully'); @@ -776,10 +783,10 @@ export async function fetchGeminiCliQuota( // First attempt with current token const result = await fetchWithAuthData(authData, accountId, verbose); - // If 401 error and we haven't refreshed yet, try refresh and retry - if (result.needsReauth && result.error?.includes('expired')) { + // Retry once with an account-scoped refresh when the quota endpoint rejects auth. + if (result.needsReauth && !attemptedRefresh) { if (verbose) console.error('[i] Got 401, attempting refresh and retry...'); - const refreshResult = await refreshGeminiToken(); + const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { const refreshedAuthData = readGeminiCliAuthData(accountId); if (refreshedAuthData) { diff --git a/tests/unit/cliproxy/auth-utils.test.ts b/tests/unit/cliproxy/auth-utils.test.ts index a6003b80..b809031a 100644 --- a/tests/unit/cliproxy/auth-utils.test.ts +++ b/tests/unit/cliproxy/auth-utils.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'bun:test'; -import { sanitizeEmail, isTokenExpired } from '../../../src/cliproxy/auth-utils'; +import { + getTokenExpiryTimestamp, + isTokenExpired, + sanitizeEmail, +} from '../../../src/cliproxy/auth-utils'; describe('Auth Utilities', () => { describe('sanitizeEmail', () => { @@ -75,14 +79,21 @@ describe('Auth Utilities', () => { expect(isTokenExpired(futureISO)).toBe(false); }); - it('should handle Unix timestamp strings', () => { - // JavaScript Date can parse numeric strings as timestamps + it('should handle Unix timestamp strings deterministically', () => { const pastTimestamp = String(Date.now() - 86400000); // Yesterday - // Note: Date parsing of pure numbers as strings is inconsistent - // This test documents the actual behavior - const result = isTokenExpired(pastTimestamp); - // The behavior depends on how Date parses the string - expect(typeof result).toBe('boolean'); + expect(isTokenExpired(pastTimestamp)).toBe(true); + }); + + it('should handle Unix timestamps provided as numbers', () => { + const futureTimestamp = Date.now() + 86400000; + expect(isTokenExpired(futureTimestamp)).toBe(false); + }); + + it('should expose normalized expiry timestamps for string and numeric inputs', () => { + const futureTimestamp = Date.now() + 60000; + expect(getTokenExpiryTimestamp(futureTimestamp)).toBe(futureTimestamp); + expect(getTokenExpiryTimestamp(String(futureTimestamp))).toBe(futureTimestamp); + expect(getTokenExpiryTimestamp('not-a-date')).toBeNull(); }); }); }); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 8c2d1b18..6df70140 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -27,10 +27,10 @@ describe('Gemini CLI Quota Fetcher', () => { let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken; let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir; - function writeGeminiToken(token: Record): string { + function writeGeminiToken(token: Record, filename = 'gemini-test.json'): string { const authDir = getProviderAuthDir('gemini'); fs.mkdirSync(authDir, { recursive: true }); - const tokenPath = path.join(authDir, 'gemini-test.json'); + const tokenPath = path.join(authDir, filename); fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2)); return tokenPath; } @@ -649,6 +649,74 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.error).toBe('Gemini quota service unavailable (HTTP 502)'); expect(result.errorDetail).toBe('[HTML error response omitted]'); }); + + it('refreshes the requested Gemini account instead of the default account', async () => { + writeGeminiToken( + { + type: 'gemini', + email: 'default@example.com', + project_id: 'default-project', + token: { + access_token: 'default-access-token', + refresh_token: 'default-refresh-token', + expiry: Date.now() + 60 * 60 * 1000, + client_id: 'default-client-id', + client_secret: 'default-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-default.json' + ); + + writeGeminiToken( + { + type: 'gemini', + email: 'target@example.com', + project_id: 'target-project', + token: { + access_token: 'target-stale-token', + refresh_token: 'target-refresh-token', + expiry: Date.now() - 1000, + client_id: 'target-client-id', + client_secret: 'target-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-target.json' + ); + + mockFetch([ + { + url: GOOGLE_TOKEN_URL, + method: 'POST', + response: { access_token: 'target-fresh-token', expires_in: 1800 }, + }, + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 200, + response: { + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }], + }, + }, + { + url: GEMINI_CODE_ASSIST_URL, + method: 'POST', + status: 503, + response: { error: { message: 'supplementary unavailable' } }, + }, + ]); + + const result = await fetchGeminiCliQuota('target@example.com'); + + expect(result.success).toBe(true); + + const [refreshRequest, quotaRequest] = getCapturedFetchRequests(); + expect(refreshRequest.url).toBe(GOOGLE_TOKEN_URL); + expect(refreshRequest.body).toContain('refresh_token=target-refresh-token'); + expect(refreshRequest.body).not.toContain('default-refresh-token'); + expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token'); + }); }); describe('direct Gemini error helper coverage', () => { diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index b4f65156..e66e4195 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -249,7 +249,7 @@ export function AccountCard({ )} - + diff --git a/ui/src/components/account/shared/account-quota-panel.tsx b/ui/src/components/account/shared/account-quota-panel.tsx index 95c4fb57..88ffa4f9 100644 --- a/ui/src/components/account/shared/account-quota-panel.tsx +++ b/ui/src/components/account/shared/account-quota-panel.tsx @@ -250,7 +250,7 @@ export function AccountQuotaPanel({ )} - + @@ -306,23 +306,8 @@ export function AccountQuotaPanel({ )} - -
-

{failureInfo.summary}

- {failureInfo.actionHint && ( -

{failureInfo.actionHint}

- )} - {failureInfo.technicalDetail && ( -

- {failureInfo.technicalDetail} -

- )} - {failureInfo.rawDetail && ( -
-                {failureInfo.rawDetail}
-              
- )} -
+ + diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 25edae1b..305cd98f 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -86,22 +86,35 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro if (!quota.success) { const failureInfo = getQuotaFailureInfo(quota); + const failureToneClass = + failureInfo?.tone === 'destructive' + ? 'text-destructive' + : failureInfo?.tone === 'warning' + ? 'text-amber-700 dark:text-amber-300' + : 'text-foreground'; + return ( -
-

- {failureInfo?.label || quota.error || 'Failed to load quota'} -

-

{failureInfo?.summary || quota.error}

+
+
+

+ {failureInfo?.label || quota.error || 'Failed to load quota'} +

+

+ {failureInfo?.summary || quota.error} +

+
{failureInfo?.actionHint && ( -

{failureInfo.actionHint}

+
+ {failureInfo.actionHint} +
)} {failureInfo?.technicalDetail && ( -

+

{failureInfo.technicalDetail} -

+
)} {failureInfo?.rawDetail && ( -
+          
             {failureInfo.rawDetail}
           
)} @@ -116,7 +129,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; return ( -
+

Model Quotas:

{tierOrder.map((tier, idx) => { const models = groups.get(tier); @@ -124,7 +137,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length); return (
- {!isFirst &&
} + {!isFirst &&
} {models.map((m) => (
@@ -159,7 +172,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro ); return ( -
+

Rate Limits:

{quota.planType &&

Plan: {quota.planType}

} {orderedWindows.map((w, index) => ( @@ -228,7 +241,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro null; return ( -
+

Rate Limits:

{orderedWindows.map((window, index) => (
!!bucket.resetTime); return ( -
+
{quota.tierLabel && (
Tier @@ -306,7 +319,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const planLabel = formatPlanLabel(quota.planType); return ( -
+

Quota Snapshots:

{planLabel &&

Plan: {planLabel}

} {snapshotRows.map(({ label, snapshot }) => { @@ -344,9 +357,11 @@ function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) { if (!resetTime) return null; return ( -
- - Resets {formatResetTime(resetTime)} +
+ + + Resets {formatResetTime(resetTime)} +
); } @@ -364,19 +379,19 @@ function CodexResetIndicators({ if (!hasSpecificReset && !fallbackResetTime) return null; return ( -
+
{fiveHourResetTime && (
- - + + 5h resets {formatResetTime(fiveHourResetTime)}
)} {weeklyResetTime && (
- - + + Weekly resets {formatResetTime(weeklyResetTime)}
diff --git a/ui/src/components/ui/popover.tsx b/ui/src/components/ui/popover.tsx index 07b74e1a..b78a6417 100644 --- a/ui/src/components/ui/popover.tsx +++ b/ui/src/components/ui/popover.tsx @@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + 'z-50 w-80 max-w-[calc(100vw-2rem)] rounded-lg border border-border/70 bg-popover p-4 text-popover-foreground shadow-xl shadow-black/10 outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className )} {...props} diff --git a/ui/src/components/ui/tooltip.tsx b/ui/src/components/ui/tooltip.tsx index d4011c57..2d5995df 100644 --- a/ui/src/components/ui/tooltip.tsx +++ b/ui/src/components/ui/tooltip.tsx @@ -32,7 +32,7 @@ function TooltipTrigger({ ...props }: React.ComponentProps) { @@ -42,13 +42,13 @@ function TooltipContent({ data-slot="tooltip-content" sideOffset={sideOffset} className={cn( - 'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance', + 'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-w-sm origin-(--radix-tooltip-content-transform-origin) rounded-lg border border-border/70 bg-popover px-3 py-2 text-left text-xs leading-relaxed text-popover-foreground shadow-xl shadow-black/10', className )} {...props} > {children} - + ); diff --git a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx index 8086b0a9..65401d33 100644 --- a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx +++ b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx @@ -133,6 +133,9 @@ describe('AccountCard grouped quota tooltip', () => { await userEvent.hover(screen.getByText('Business')); expect((await screen.findAllByText('Plan: team')).length).toBeGreaterThan(0); expect(screen.getAllByText('5h usage limit').length).toBeGreaterThan(0); + const tooltipContent = document.querySelector('[data-slot="tooltip-content"]'); + expect(tooltipContent?.className).toContain('bg-popover'); + expect(tooltipContent?.className).toContain('text-popover-foreground'); await userEvent.hover(screen.getByText('Personal')); expect((await screen.findAllByText('Plan: plus')).length).toBeGreaterThan(0); diff --git a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx index 73ab0923..d47bca8b 100644 --- a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx +++ b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx @@ -90,4 +90,28 @@ describe('QuotaTooltipContent', () => { vi.useRealTimers(); }); + + it('renders failure summaries, action hints, and raw details with readable structure', () => { + const quota = createGeminiQuotaResult({ + success: false, + buckets: [], + error: 'Request had invalid authentication credentials.', + httpStatus: 401, + errorCode: 'UNAUTHENTICATED', + errorDetail: + '{"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}}', + actionHint: 'Run ccs gemini --auth to reconnect this account.', + needsReauth: true, + }); + + render(); + + expect(screen.getByText('Reauth')).toBeInTheDocument(); + expect(screen.getByText('Request had invalid authentication credentials.')).toBeInTheDocument(); + expect( + screen.getByText('Run ccs gemini --auth to reconnect this account.') + ).toBeInTheDocument(); + expect(screen.getByText('HTTP 401 | UNAUTHENTICATED')).toBeInTheDocument(); + expect(screen.getByText(/"status":"UNAUTHENTICATED"/)).toBeInTheDocument(); + }); }); From 7ac6d0a4d4a08113b48c008c1850dc6b089b66d1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 00:14:51 -0400 Subject: [PATCH 2/2] fix(review): harden quota retry and tooltip edge cases --- src/cliproxy/auth-utils.ts | 13 ++- src/cliproxy/quota-fetcher-gemini-cli.ts | 6 +- tests/unit/cliproxy/auth-utils.test.ts | 9 ++ .../cliproxy/quota-fetcher-gemini-cli.test.ts | 105 ++++++++++++++++++ .../account/flow-viz/account-card.tsx | 2 +- .../account/shared/account-quota-panel.tsx | 4 +- .../shared/quota-tooltip-content.tsx | 18 +-- ui/src/components/ui/tooltip.tsx | 2 +- .../account/flow-viz/account-card.test.tsx | 13 ++- .../shared/account-quota-panel.test.tsx | 52 +++++++++ .../shared/quota-tooltip-content.test.tsx | 3 +- 11 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 ui/tests/unit/components/account/shared/account-quota-panel.test.tsx diff --git a/src/cliproxy/auth-utils.ts b/src/cliproxy/auth-utils.ts index 79f8628f..d7f19c2a 100644 --- a/src/cliproxy/auth-utils.ts +++ b/src/cliproxy/auth-utils.ts @@ -21,9 +21,18 @@ export function getTokenExpiryTimestamp(expiredValue?: string | number | null): return null; } + const normalizeNumericTimestamp = (value: number): number | null => { + if (!Number.isFinite(value) || value <= 0) { + return null; + } + + // Support Unix seconds from older token stores while preserving millisecond timestamps. + return value < 1_000_000_000_000 ? value * 1000 : value; + }; + try { if (typeof expiredValue === 'number') { - return Number.isFinite(expiredValue) ? expiredValue : null; + return normalizeNumericTimestamp(expiredValue); } const trimmed = expiredValue.trim(); @@ -33,7 +42,7 @@ export function getTokenExpiryTimestamp(expiredValue?: string | number | null): if (/^\d+$/.test(trimmed)) { const numericTimestamp = Number(trimmed); - return Number.isFinite(numericTimestamp) ? numericTimestamp : null; + return normalizeNumericTimestamp(numericTimestamp); } const expiredDate = new Date(trimmed); diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 64882d4c..1dc8fe7a 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -745,10 +745,9 @@ export async function fetchGeminiCliQuota( const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); const shouldRefresh = authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS; - let attemptedRefresh = false; + let refreshedBeforeQuotaFetch = false; if (shouldRefresh) { - attemptedRefresh = true; if (verbose) console.error( authData.isExpired @@ -758,6 +757,7 @@ export async function fetchGeminiCliQuota( const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { + refreshedBeforeQuotaFetch = true; if (verbose) console.error('[i] Token refreshed successfully'); // Re-read auth data after successful refresh const refreshedAuthData = readGeminiCliAuthData(accountId); @@ -784,7 +784,7 @@ export async function fetchGeminiCliQuota( const result = await fetchWithAuthData(authData, accountId, verbose); // Retry once with an account-scoped refresh when the quota endpoint rejects auth. - if (result.needsReauth && !attemptedRefresh) { + if (result.needsReauth && !refreshedBeforeQuotaFetch) { if (verbose) console.error('[i] Got 401, attempting refresh and retry...'); const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { diff --git a/tests/unit/cliproxy/auth-utils.test.ts b/tests/unit/cliproxy/auth-utils.test.ts index b809031a..f6944882 100644 --- a/tests/unit/cliproxy/auth-utils.test.ts +++ b/tests/unit/cliproxy/auth-utils.test.ts @@ -89,10 +89,19 @@ describe('Auth Utilities', () => { expect(isTokenExpired(futureTimestamp)).toBe(false); }); + it('should treat Unix-seconds values as seconds, not milliseconds', () => { + const futureUnixSeconds = Math.floor((Date.now() + 60000) / 1000); + expect(isTokenExpired(futureUnixSeconds)).toBe(false); + expect(isTokenExpired(String(futureUnixSeconds))).toBe(false); + }); + it('should expose normalized expiry timestamps for string and numeric inputs', () => { const futureTimestamp = Date.now() + 60000; expect(getTokenExpiryTimestamp(futureTimestamp)).toBe(futureTimestamp); expect(getTokenExpiryTimestamp(String(futureTimestamp))).toBe(futureTimestamp); + const futureUnixSeconds = Math.floor((Date.now() + 60000) / 1000); + expect(getTokenExpiryTimestamp(futureUnixSeconds)).toBe(futureUnixSeconds * 1000); + expect(getTokenExpiryTimestamp(String(futureUnixSeconds))).toBe(futureUnixSeconds * 1000); expect(getTokenExpiryTimestamp('not-a-date')).toBeNull(); }); }); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 6df70140..1966c6d5 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -717,6 +717,111 @@ describe('Gemini CLI Quota Fetcher', () => { expect(refreshRequest.body).not.toContain('default-refresh-token'); expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token'); }); + + it('retries a 401 quota failure after a transient proactive refresh failure', async () => { + writeGeminiToken( + { + type: 'gemini', + email: 'retry@example.com', + project_id: 'retry-project', + token: { + access_token: 'retry-stale-token', + refresh_token: 'retry-refresh-token', + expiry: Date.now() + 60 * 1000, + client_id: 'retry-client-id', + client_secret: 'retry-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-retry.json' + ); + + mockFetch([ + { + url: GOOGLE_TOKEN_URL, + method: 'POST', + response: { access_token: 'unused-default', expires_in: 1800 }, + }, + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 200, + response: { + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], + }, + }, + { + url: GEMINI_CODE_ASSIST_URL, + method: 'POST', + status: 503, + response: { error: { message: 'supplementary unavailable' } }, + }, + ]); + + const originalFetch = globalThis.fetch; + let refreshAttempt = 0; + let quotaAttempt = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + if (url === GOOGLE_TOKEN_URL) { + refreshAttempt += 1; + return refreshAttempt === 1 + ? new Response(JSON.stringify({ error: 'temporarily_unavailable' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + : new Response(JSON.stringify({ access_token: 'retry-fresh-token', expires_in: 1800 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (url === GEMINI_QUOTA_URL) { + quotaAttempt += 1; + return quotaAttempt === 1 + ? new Response( + JSON.stringify({ + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ) + : new Response( + JSON.stringify({ + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + return originalFetch(input, init); + }) as typeof fetch; + + try { + const result = await fetchGeminiCliQuota('retry@example.com'); + + expect(result.success).toBe(true); + expect(refreshAttempt).toBe(2); + expect(quotaAttempt).toBe(2); + + const storedToken = JSON.parse( + fs.readFileSync(path.join(getProviderAuthDir('gemini'), 'gemini-retry.json'), 'utf8') + ) as { token?: { access_token?: string } }; + expect(storedToken.token?.access_token).toBe('retry-fresh-token'); + } finally { + globalThis.fetch = originalFetch; + } + }); }); describe('direct Gemini error helper coverage', () => { diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index e66e4195..debb9d8b 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -249,7 +249,7 @@ export function AccountCard({ )}
- + diff --git a/ui/src/components/account/shared/account-quota-panel.tsx b/ui/src/components/account/shared/account-quota-panel.tsx index 88ffa4f9..fc52019c 100644 --- a/ui/src/components/account/shared/account-quota-panel.tsx +++ b/ui/src/components/account/shared/account-quota-panel.tsx @@ -250,7 +250,7 @@ export function AccountQuotaPanel({
)} - + @@ -306,7 +306,7 @@ export function AccountQuotaPanel({
)} - + diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 305cd98f..5dcda088 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -27,6 +27,8 @@ interface QuotaTooltipContentProps { resetTime: string | null; } +const lowQuotaTextClass = 'text-red-700 dark:text-red-400'; + function formatPlanLabel(planType: string | null | undefined): string | null { if (!planType) return null; const normalized = planType @@ -94,7 +96,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro : 'text-foreground'; return ( -
+

{failureInfo?.label || quota.error || 'Failed to load quota'} @@ -140,10 +142,10 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro {!isFirst &&

} {models.map((m) => (
- + {m.displayName} - + {m.percentage}%
@@ -180,7 +182,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro key={`${w.label}-${w.resetAt ?? 'no-reset'}-${index}`} className="flex justify-between gap-4" > - + {getCodexWindowDisplayLabel(w, orderedWindows)} {w.remainingPercent}% @@ -248,7 +250,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro key={`${window.rateLimitType}-${window.resetAt ?? 'no-reset'}-${window.status}-${index}`} className="flex justify-between gap-4" > - + {getClaudeWindowDisplayLabel(window.rateLimitType, window.label)} {window.remainingPercent}% @@ -285,7 +287,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro {quota.buckets.map((b) => (
- + {b.label} {b.tokenType ? ` (${b.tokenType})` : ''} @@ -327,8 +329,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro return (
- {label} - + {label} + {snapshot.unlimited ? 'Unlimited' : `${formatQuotaPercent(snapshot.percentRemaining)}%`} diff --git a/ui/src/components/ui/tooltip.tsx b/ui/src/components/ui/tooltip.tsx index 2d5995df..71e1ed7a 100644 --- a/ui/src/components/ui/tooltip.tsx +++ b/ui/src/components/ui/tooltip.tsx @@ -42,7 +42,7 @@ function TooltipContent({ data-slot="tooltip-content" sideOffset={sideOffset} className={cn( - 'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-w-sm origin-(--radix-tooltip-content-transform-origin) rounded-lg border border-border/70 bg-popover px-3 py-2 text-left text-xs leading-relaxed text-popover-foreground shadow-xl shadow-black/10', + 'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit max-w-[calc(100vw-2rem)] origin-(--radix-tooltip-content-transform-origin) rounded-lg border border-border/70 bg-popover px-3 py-2 text-left text-xs leading-relaxed text-popover-foreground shadow-xl shadow-black/10', className )} {...props} diff --git a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx index 65401d33..539e48e3 100644 --- a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx +++ b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx @@ -131,14 +131,21 @@ describe('AccountCard grouped quota tooltip', () => { ); await userEvent.hover(screen.getByText('Business')); - expect((await screen.findAllByText('Plan: team')).length).toBeGreaterThan(0); + const businessPlan = (await screen.findAllByText('Plan: team')).find((node) => + node.closest('[data-slot="tooltip-content"]') + ); + expect(businessPlan).toBeInTheDocument(); expect(screen.getAllByText('5h usage limit').length).toBeGreaterThan(0); - const tooltipContent = document.querySelector('[data-slot="tooltip-content"]'); + const tooltipContent = businessPlan.closest('[data-slot="tooltip-content"]'); expect(tooltipContent?.className).toContain('bg-popover'); expect(tooltipContent?.className).toContain('text-popover-foreground'); + expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]'); await userEvent.hover(screen.getByText('Personal')); - expect((await screen.findAllByText('Plan: plus')).length).toBeGreaterThan(0); + const personalPlan = (await screen.findAllByText('Plan: plus')).find((node) => + node.closest('[data-slot="tooltip-content"]') + ); + expect(personalPlan).toBeInTheDocument(); expect(screen.getAllByText('Weekly usage limit').length).toBeGreaterThan(0); }); }); diff --git a/ui/tests/unit/components/account/shared/account-quota-panel.test.tsx b/ui/tests/unit/components/account/shared/account-quota-panel.test.tsx new file mode 100644 index 00000000..4263945f --- /dev/null +++ b/ui/tests/unit/components/account/shared/account-quota-panel.test.tsx @@ -0,0 +1,52 @@ +import { render, screen, userEvent } from '@tests/setup/test-utils'; +import { describe, expect, it } from 'vitest'; +import { AccountQuotaPanel } from '@/components/account/shared/account-quota-panel'; +import type { GeminiCliQuotaResult } from '@/lib/api-client'; + +function createGeminiFailureQuota(): GeminiCliQuotaResult { + return { + success: false, + buckets: [], + projectId: 'test-project', + lastUpdated: Date.now(), + error: 'Request had invalid authentication credentials.', + httpStatus: 401, + errorCode: 'UNAUTHENTICATED', + errorDetail: + '{"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}}', + actionHint: 'Run ccs gemini --auth to reconnect this account.', + needsReauth: true, + }; +} + +describe('AccountQuotaPanel failure tooltip', () => { + it('renders the shared failure tooltip content with viewport-safe shell classes', async () => { + render( + + ); + + await userEvent.hover(screen.getByText('Reauth')); + + const summary = ( + await screen.findAllByText('Request had invalid authentication credentials.') + ).find((node) => node.closest('[data-slot="tooltip-content"]')); + expect(summary).toBeInTheDocument(); + const tooltipContent = summary.closest('[data-slot="tooltip-content"]'); + const actionHint = screen + .getAllByText('Run ccs gemini --auth to reconnect this account.') + .find((node) => node.closest('[data-slot="tooltip-content"]') === tooltipContent); + const technicalDetail = screen + .getAllByText('HTTP 401 | UNAUTHENTICATED') + .find((node) => node.closest('[data-slot="tooltip-content"]') === tooltipContent); + expect(actionHint).toBeInTheDocument(); + expect(technicalDetail).toBeInTheDocument(); + expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]'); + expect(tooltipContent?.className).toContain('bg-popover'); + expect(tooltipContent?.className).toContain('text-popover-foreground'); + }); +}); diff --git a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx index d47bca8b..bada72b1 100644 --- a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx +++ b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx @@ -104,7 +104,7 @@ describe('QuotaTooltipContent', () => { needsReauth: true, }); - render(); + const { container } = render(); expect(screen.getByText('Reauth')).toBeInTheDocument(); expect(screen.getByText('Request had invalid authentication credentials.')).toBeInTheDocument(); @@ -113,5 +113,6 @@ describe('QuotaTooltipContent', () => { ).toBeInTheDocument(); expect(screen.getByText('HTTP 401 | UNAUTHENTICATED')).toBeInTheDocument(); expect(screen.getByText(/"status":"UNAUTHENTICATED"/)).toBeInTheDocument(); + expect(container.firstChild).not.toHaveClass('min-w-[16rem]'); }); });