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]'); }); });